feat(prompt): introduce PromptExecutorHooks and align ContextualPromp…#1764
feat(prompt): introduce PromptExecutorHooks and align ContextualPromp…#1764
Conversation
...ts-core/src/commonMain/kotlin/ai/koog/agents/core/agent/session/AIAgentLLMReadSessionImpl.kt
Outdated
Show resolved
Hide resolved
79da560 to
2f519c7
Compare
2f519c7 to
45f6a42
Compare
…inversing the control ownership of contextual hooks # Conflicts: # agents/agents-core/src/commonMain/kotlin/ai/koog/agents/core/agent/session/AIAgentLLMReadSessionImpl.kt # prompt/prompt-executor/prompt-executor-model/src/jvmMain/kotlin/ai/koog/prompt/executor/model/PromptExecutor.kt
45f6a42 to
e8a2283
Compare
| model: LLModel, | ||
| tools: List<ToolDescriptor> = emptyList() | ||
| tools: List<ToolDescriptor> = emptyList(), | ||
| hooks: PromptExecutorHooks? = null |
There was a problem hiding this comment.
@Amaneusz, I like the new design for the hooks! I have one question/concern about this approach that I want to discuss. Could you please clarify why do you use hooks as parameter for every method instead of including hooks into the PromptExecutor API itself? I mean something like this:
public interface PromptExecutorAPI : AutoCloseable {
public suspend fun onModelChoiceFailed(intent: InitialExecutionIntent, error: Throwable)
public suspend fun beforeExecution(intent: InitialExecutionIntent, effectiveModel: LLModel): ExecutionArgOverrides
public suspend fun onFailure(intent: ResolvedExecutionIntent, effectiveModel: LLModel, error: Throwable)
public suspend fun execute(...): List<Message.Response>
public fun executeStreaming(...): Flow<StreamFrame>
Updated API should enforce any derived class to add implementation for hooks. Also, it does not need to handle the outer + inner hooks through the methods. You can just wrap the original hooks into a pipeline logic:
public class ContextualPromptExecutor(private val executor: PromptExecutor, ... ) {
...
override suspend fun onCompleted(...) {
context.pipeline.onLLMCallCompleted(...)
executor.onCompleted() // executor instance is a base prompt executor passed to the contextual one as a parameter
}
}
I looked through current implementations and I found that it might be quite hard to know that you need to wrap your logic into hooks when you make your own PromptExecutor implementation. It might be I miss some important design points here. Very welcome to discuss this.
There was a problem hiding this comment.
Hey @sdubov, really good question - I had similar idea at the beginning but it won't work out for us. Let me explain the reasoning behind the design.
tl;dr - there are two main rules to follow:
- The caller must define the logic of the hook (it's the
Contextutalthat knows about thepipeline) - The executor must define the moment of hook trigger (it's the
Multi/Routingthat know when the prompt is actually executed -execute*methods are not only about executing, there's more arbitrary logic to them that we can't control)
Longer take:
The core problem this solves: the caller needs to observe execution facts that only the executor knows — specifically, which model actually ran. The caller requests a model, but executors can silently substitute it (fallback, routing). By the time execute() returns, that information is gone.
Solving this requires two things simultaneously:
- the caller defines what to do with those facts (pipeline callbacks, tracing — context the executor has no access to)
- the executor fires at the moment the facts exist (after model resolution — a moment invisible from outside)
This is why hooks can't live on the interface as methods. With your proposed design (where hooks are owned by executor):
contextual.execute(prompt, model, tools)
nested.execute(prompt, model, tools)
// `nested` logic before model choice
val effectiveModel = chooseModel()
// `nested` logic after model choice
nested.onCompleted(effectiveModel, result)
contextual.OnCompleted // no way to get `effectiveModel` here
The inner executor fires its own methods, not the wrapper's. To fix that, you'd need the inner executor to hold a reference back to the outer wrapper — that would require some runtime injection mechanism - just as the on in this PR.
Hooks as a call parameter satisfy both constraints cleanly:
contextual.execute(prompt, model, tools, outerHooks)
nested.execute(..., hooks = ContextualHooks(pipeline, outerHooks))
// `nested` logic before model choice
val effectiveModel = chooseModel()
// `nested` logic after model choice
hooks.onCompleted(effectiveModel, result) // hooks allow executors to communicate
pipeline.onLLMCallCompleted(effectiveModel, ...)
outerHooks?.onCompleted(...)
The caller provides the logic, the executor decides when to invoke it.
There was a problem hiding this comment.
@Amaneusz, thank you for detailed explanation of the approach and a new API changes. As we discussed it might be worth extracting the Hookable interface separately. So, we can use it for PromptExecutorAPI and append extra logic to set/get hooks that are used internally. We do not need to change the current PromptExecutor API in that case and append hooks parameter for every method. I'm open for discussion here. Please let me know wdyt?
| * keeping each `execute` / `executeStreaming` override focused on client selection and | ||
| * the LLM call itself. | ||
| */ | ||
| public object ExecutorHooksHelper { |
There was a problem hiding this comment.
Up to your decision - maybe it worth extracting this into a separate file to not mix helper methods and the core hooks logic?
| * (ie [ai.koog.prompt.executor.llms.MultiLLMPromptExecutor.fallback]). | ||
| * If given [PromptExecutor] implementation does not support dynamic model selection, it should use initially provided model - [InitialExecutionIntent.model] | ||
| */ | ||
| public suspend fun <T> executeWithHook( |
There was a problem hiding this comment.
It seems for me that this method should be internal or even private. As far as I understand, hooks are very internal logic that should not be exposed outside. Please let me know if I miss anything.
|
|
||
| /** Hooks for [PromptExecutorAPI.execute]. */ | ||
| public val execute: SimpleExecutorHook<List<Message.Response>> | ||
| get() = object : SimpleExecutorHook<List<Message.Response>> {} |
There was a problem hiding this comment.
Here and below. It would be better to keep these objects in static variables. Currently, they are created on every get() invocation.
|
|
||
| return response | ||
| return executeWithHook(InitialExecutionIntent(prompt, tools, model), hook = hooks?.execute) { finalIntent -> | ||
| llmClient.execute(finalIntent.prompt, model, finalIntent.tools) |
There was a problem hiding this comment.
Here and below, should not it be finalIntent.model in the llmClient.execute(...) call?
| model: LLModel, | ||
| tools: List<ToolDescriptor> = emptyList() | ||
| tools: List<ToolDescriptor> = emptyList(), | ||
| hooks: PromptExecutorHooks? = null |
There was a problem hiding this comment.
@Amaneusz, thank you for detailed explanation of the approach and a new API changes. As we discussed it might be worth extracting the Hookable interface separately. So, we can use it for PromptExecutorAPI and append extra logic to set/get hooks that are used internally. We do not need to change the current PromptExecutor API in that case and append hooks parameter for every method. I'm open for discussion here. Please let me know wdyt?
No description provided.