Merge pull request #80 from dj-oyu/feat/upstream-test-compat
feat: implement upstream event/steering/interrupt features
This commit is contained in:
commit
9d9a986ccd
3 changed files with 234 additions and 16 deletions
|
|
@ -479,12 +479,13 @@ func (al *AgentLoop) llmWorker(ctx context.Context, queue <-chan bus.InboundMess
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
al.llmWorkerNormal(ctx, msg)
|
al.llmWorkerNormal(ctx, msg, queue)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// llmWorkerNormal processes a single non-PDF message.
|
// llmWorkerNormal processes a single non-PDF message.
|
||||||
func (al *AgentLoop) llmWorkerNormal(ctx context.Context, msg bus.InboundMessage) {
|
// queue is optional (nil when called outside Run loop).
|
||||||
|
func (al *AgentLoop) llmWorkerNormal(ctx context.Context, msg bus.InboundMessage, queue <-chan bus.InboundMessage) {
|
||||||
al.activeRequests.Add(1)
|
al.activeRequests.Add(1)
|
||||||
defer al.activeRequests.Done()
|
defer al.activeRequests.Done()
|
||||||
|
|
||||||
|
|
@ -508,9 +509,66 @@ func (al *AgentLoop) llmWorkerNormal(ctx context.Context, msg bus.InboundMessage
|
||||||
response = fmt.Sprintf("Error processing message: %v", err)
|
response = fmt.Sprintf("Error processing message: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Auto-continue: if inbound messages arrived in the queue while this
|
||||||
|
// turn was running, treat them as steering continuations so the agent
|
||||||
|
// responds with the full context instead of two separate turns.
|
||||||
|
if queue != nil {
|
||||||
|
if drained := al.drainQueueAsSteering(queue, msg); len(drained) > 0 {
|
||||||
|
agent := al.agentForSession(msg.SessionKey)
|
||||||
|
if agent == nil {
|
||||||
|
agent = al.registry.GetDefaultAgent()
|
||||||
|
}
|
||||||
|
if agent != nil {
|
||||||
|
sessionKey := msg.SessionKey
|
||||||
|
if sessionKey == "" {
|
||||||
|
route, _, _ := al.resolveMessageRoute(msg)
|
||||||
|
sessionKey = resolveScopeKey(route, msg.SessionKey)
|
||||||
|
}
|
||||||
|
contResp, contErr := al.continueWithSteeringMessages(
|
||||||
|
ctx, agent, sessionKey, msg.Channel, msg.ChatID, drained,
|
||||||
|
)
|
||||||
|
if contErr == nil && contResp != "" {
|
||||||
|
response = contResp
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
al.sendResponseIfNeeded(ctx, msg, response)
|
al.sendResponseIfNeeded(ctx, msg, response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// drainQueueAsSteering non-blocking drains pending messages from the
|
||||||
|
// llmQueue that belong to the same chat as the original message and
|
||||||
|
// returns them as steering-style provider messages.
|
||||||
|
func (al *AgentLoop) drainQueueAsSteering(
|
||||||
|
queue <-chan bus.InboundMessage,
|
||||||
|
orig bus.InboundMessage,
|
||||||
|
) []providers.Message {
|
||||||
|
var msgs []providers.Message
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case m, ok := <-queue:
|
||||||
|
if !ok {
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
if m.Channel == orig.Channel && m.ChatID == orig.ChatID {
|
||||||
|
msgs = append(msgs, providers.Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: m.Content,
|
||||||
|
})
|
||||||
|
} else {
|
||||||
|
// Re-queue by pushing to steering for later processing
|
||||||
|
al.enqueueSteeringMessage("", "", providers.Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: m.Content,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
default:
|
||||||
|
return msgs
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// llmWorkerPDF handles a bare-PDF message with two-phase follow-up collection.
|
// llmWorkerPDF handles a bare-PDF message with two-phase follow-up collection.
|
||||||
func (al *AgentLoop) llmWorkerPDF(ctx context.Context, msg bus.InboundMessage, queue <-chan bus.InboundMessage) {
|
func (al *AgentLoop) llmWorkerPDF(ctx context.Context, msg bus.InboundMessage, queue <-chan bus.InboundMessage) {
|
||||||
// Phase 1: wait for OCR keywords (figures/図版) — up to 5 seconds.
|
// Phase 1: wait for OCR keywords (figures/図版) — up to 5 seconds.
|
||||||
|
|
@ -553,13 +611,13 @@ func (al *AgentLoop) llmWorkerPDF(ctx context.Context, msg bus.InboundMessage, q
|
||||||
Content: followUpText,
|
Content: followUpText,
|
||||||
Metadata: msg.Metadata,
|
Metadata: msg.Metadata,
|
||||||
}
|
}
|
||||||
al.llmWorkerNormal(ctx, followUpMsg)
|
al.llmWorkerNormal(ctx, followUpMsg, nil)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Re-queue messages from other chats that were buffered.
|
// Re-queue messages from other chats that were buffered.
|
||||||
otherMsgs := extractNonChatMessages(buffered, msg.ChatID)
|
otherMsgs := extractNonChatMessages(buffered, msg.ChatID)
|
||||||
for _, other := range otherMsgs {
|
for _, other := range otherMsgs {
|
||||||
al.llmWorkerNormal(ctx, other)
|
al.llmWorkerNormal(ctx, other, nil)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1179,7 +1237,31 @@ func (al *AgentLoop) ProcessDirectWithChannel(
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
return al.processMessage(ctx, msg)
|
response, err := al.processMessage(ctx, msg)
|
||||||
|
if err != nil {
|
||||||
|
return response, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// If steering messages arrived during the LLM call, the initial
|
||||||
|
// response is stale. Discard it and do a continuation turn that
|
||||||
|
// includes the steering messages for a fresh response.
|
||||||
|
steeringMsgs := al.dequeueSteeringMessagesForScopeWithFallback(sessionKey)
|
||||||
|
if len(steeringMsgs) > 0 {
|
||||||
|
agent := al.agentForSession(sessionKey)
|
||||||
|
if agent == nil {
|
||||||
|
agent = al.registry.GetDefaultAgent()
|
||||||
|
}
|
||||||
|
if agent != nil {
|
||||||
|
contResp, contErr := al.continueWithSteeringMessages(
|
||||||
|
ctx, agent, sessionKey, channel, chatID, steeringMsgs,
|
||||||
|
)
|
||||||
|
if contErr == nil && contResp != "" {
|
||||||
|
return contResp, nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return response, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// ProcessHeartbeat processes a heartbeat request without session history.
|
// ProcessHeartbeat processes a heartbeat request without session history.
|
||||||
|
|
|
||||||
|
|
@ -62,6 +62,14 @@ func (al *AgentLoop) buildCommandsRuntime(agent *AgentInstance, sessionKey strin
|
||||||
}
|
}
|
||||||
old := agent.Model
|
old := agent.Model
|
||||||
agent.Model = value
|
agent.Model = value
|
||||||
|
// Rebuild candidates so subsequent LLM calls use the new
|
||||||
|
// model's provider/endpoint instead of the old one.
|
||||||
|
agent.Candidates = resolveModelCandidates(
|
||||||
|
cfg,
|
||||||
|
cfg.Agents.Defaults.Provider,
|
||||||
|
value,
|
||||||
|
nil,
|
||||||
|
)
|
||||||
return old, nil
|
return old, nil
|
||||||
},
|
},
|
||||||
SwitchChannel: func(value string) error {
|
SwitchChannel: func(value string) error {
|
||||||
|
|
|
||||||
|
|
@ -33,6 +33,10 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
scope := al.newTurnEventScope(agent.ID, opts.SessionKey)
|
scope := al.newTurnEventScope(agent.ID, opts.SessionKey)
|
||||||
turnStart := time.Now()
|
turnStart := time.Now()
|
||||||
|
|
||||||
|
// Create a cancelable context for hard abort support
|
||||||
|
turnCtx, turnCancelFn := context.WithCancel(ctx)
|
||||||
|
defer turnCancelFn()
|
||||||
|
|
||||||
// Register a turnState so the interrupt API can find this turn
|
// Register a turnState so the interrupt API can find this turn
|
||||||
ts := &turnState{
|
ts := &turnState{
|
||||||
turnID: scope.turnID,
|
turnID: scope.turnID,
|
||||||
|
|
@ -44,6 +48,12 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
phase: TurnPhaseRunning,
|
phase: TurnPhaseRunning,
|
||||||
startedAt: turnStart,
|
startedAt: turnStart,
|
||||||
agent: agent,
|
agent: agent,
|
||||||
|
turnCancel: turnCancelFn,
|
||||||
|
}
|
||||||
|
// Bind session store and capture initial history length for rollback
|
||||||
|
if agent.Sessions != nil {
|
||||||
|
ts.session = agent.Sessions
|
||||||
|
ts.initialHistoryLength = len(agent.Sessions.GetHistory(opts.SessionKey))
|
||||||
}
|
}
|
||||||
al.registerActiveTurn(ts)
|
al.registerActiveTurn(ts)
|
||||||
defer al.clearActiveTurn(ts)
|
defer al.clearActiveTurn(ts)
|
||||||
|
|
@ -65,7 +75,7 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
|
|
||||||
// -0. Create cancelable child context and register active task
|
// -0. Create cancelable child context and register active task
|
||||||
|
|
||||||
taskCtx, taskCancel := context.WithCancel(ctx)
|
taskCtx, taskCancel := context.WithCancel(turnCtx)
|
||||||
|
|
||||||
defer taskCancel()
|
defer taskCancel()
|
||||||
|
|
||||||
|
|
@ -354,7 +364,9 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
historyMsg = opts.HistoryMessage
|
historyMsg = opts.HistoryMessage
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if historyMsg != "" {
|
||||||
agent.Sessions.AddMessage(opts.SessionKey, "user", historyMsg)
|
agent.Sessions.AddMessage(opts.SessionKey, "user", historyMsg)
|
||||||
|
}
|
||||||
|
|
||||||
// 4. Record user prompt for stats
|
// 4. Record user prompt for stats
|
||||||
|
|
||||||
|
|
@ -413,6 +425,30 @@ func (al *AgentLoop) runAgentLoop(ctx context.Context, agent *AgentInstance, opt
|
||||||
|
|
||||||
finalContent, iteration, err = al.runLLMIteration(ctx, agent, messages, opts, task, curPlanStatus, scope)
|
finalContent, iteration, err = al.runLLMIteration(ctx, agent, messages, opts, task, curPlanStatus, scope)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
// Check for hard abort: if the turn was hard-aborted, restore
|
||||||
|
// session history and return empty response.
|
||||||
|
ts.mu.RLock()
|
||||||
|
isHardAbort := ts.hardAbort
|
||||||
|
ts.mu.RUnlock()
|
||||||
|
if isHardAbort {
|
||||||
|
if ts.session != nil {
|
||||||
|
history := ts.session.GetHistory(opts.SessionKey)
|
||||||
|
if ts.initialHistoryLength < len(history) {
|
||||||
|
ts.session.SetHistory(opts.SessionKey, history[:ts.initialHistoryLength])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
abortMeta := EventMeta{
|
||||||
|
AgentID: agent.ID,
|
||||||
|
TurnID: scope.turnID,
|
||||||
|
SessionKey: opts.SessionKey,
|
||||||
|
Iteration: iteration,
|
||||||
|
}
|
||||||
|
al.emitEvent(EventKindTurnEnd, abortMeta, TurnEndPayload{
|
||||||
|
Status: TurnEndStatusAborted,
|
||||||
|
Duration: time.Since(turnStart),
|
||||||
|
})
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
return "", err
|
return "", err
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -868,10 +904,24 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
|
|
||||||
// Also inject any initial steering messages from the process options
|
// Also inject any initial steering messages from the process options
|
||||||
if len(opts.InitialSteeringMessages) > 0 {
|
if len(opts.InitialSteeringMessages) > 0 {
|
||||||
messages = append(messages, opts.InitialSteeringMessages...)
|
// Persist original refs in session history
|
||||||
|
for _, sm := range opts.InitialSteeringMessages {
|
||||||
|
agent.Sessions.AddFullMessage(opts.SessionKey, sm)
|
||||||
|
}
|
||||||
|
// Resolve media refs for the provider call while keeping
|
||||||
|
// the originals in session history with raw refs.
|
||||||
|
cfg := al.GetConfig()
|
||||||
|
maxMedia := cfg.Agents.Defaults.GetMaxMediaSize()
|
||||||
|
resolved := resolveMediaRefs(opts.InitialSteeringMessages, al.mediaStore, maxMedia)
|
||||||
|
messages = append(messages, resolved...)
|
||||||
}
|
}
|
||||||
|
|
||||||
for iteration < agent.MaxIterations {
|
for iteration < agent.MaxIterations {
|
||||||
|
// Check for context cancellation (e.g. hard abort) before each iteration
|
||||||
|
if ctx.Err() != nil {
|
||||||
|
return "", iteration, ctx.Err()
|
||||||
|
}
|
||||||
|
|
||||||
iteration++
|
iteration++
|
||||||
|
|
||||||
if msg := hooks.OnIterationStart(iteration); msg != "" {
|
if msg := hooks.OnIterationStart(iteration); msg != "" {
|
||||||
|
|
@ -1014,7 +1064,16 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
HasReasoning: response.Reasoning != "",
|
HasReasoning: response.Reasoning != "",
|
||||||
})
|
})
|
||||||
|
|
||||||
go al.handleReasoning(ctx, response.Reasoning, opts.Channel, al.targetReasoningChannelID(opts.Channel))
|
reasoningText := response.Reasoning
|
||||||
|
if reasoningText == "" {
|
||||||
|
reasoningText = response.ReasoningContent
|
||||||
|
}
|
||||||
|
go al.handleReasoning(
|
||||||
|
context.WithoutCancel(ctx),
|
||||||
|
reasoningText,
|
||||||
|
opts.Channel,
|
||||||
|
al.targetReasoningChannelID(opts.Channel),
|
||||||
|
)
|
||||||
|
|
||||||
logger.DebugCF("agent", "LLM response",
|
logger.DebugCF("agent", "LLM response",
|
||||||
map[string]any{
|
map[string]any{
|
||||||
|
|
@ -1090,12 +1149,36 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
|
agent.Sessions.AddFullMessage(opts.SessionKey, assistantMsg)
|
||||||
|
|
||||||
// Execute tool calls and collect results
|
// Execute tool calls and collect results
|
||||||
lastBlocker := al.executeToolCalls(ctx, agent, normalizedToolCalls, &messages, opts, hooks, iteration, scope)
|
execResult := al.executeToolCalls(ctx, agent, normalizedToolCalls, &messages, opts, hooks, iteration, scope)
|
||||||
|
|
||||||
|
// Graceful interrupt: make a terminal LLM call with no tool
|
||||||
|
// definitions so the agent can produce a final summary.
|
||||||
|
if execResult.gracefulInterrupt {
|
||||||
|
hintMsg := "Interrupt requested. Stop scheduling tools and provide a short final summary."
|
||||||
|
if execResult.gracefulHint != "" {
|
||||||
|
hintMsg += "\n\nInterrupt hint: " + execResult.gracefulHint
|
||||||
|
}
|
||||||
|
messages = append(messages, providers.Message{
|
||||||
|
Role: "user",
|
||||||
|
Content: hintMsg,
|
||||||
|
})
|
||||||
|
|
||||||
|
terminalResp, termErr := al.callLLMWithRetry(
|
||||||
|
ctx, agent, &messages, opts,
|
||||||
|
nil, // no tool definitions
|
||||||
|
al.selectCandidates(agent, "", messages),
|
||||||
|
agent.Model, nil, iteration+1, scope,
|
||||||
|
)
|
||||||
|
if termErr == nil && terminalResp != nil {
|
||||||
|
finalContent = terminalResp.Content
|
||||||
|
}
|
||||||
|
break
|
||||||
|
}
|
||||||
|
|
||||||
// Tick TTL-based tool expiry after execution
|
// Tick TTL-based tool expiry after execution
|
||||||
agent.Tools.TickTTL()
|
agent.Tools.TickTTL()
|
||||||
|
|
||||||
hooks.InjectReminders(iteration, &messages, lastBlocker)
|
hooks.InjectReminders(iteration, &messages, execResult.lastBlocker)
|
||||||
hooks.RefreshSystemPrompt(messages)
|
hooks.RefreshSystemPrompt(messages)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -1107,6 +1190,13 @@ func (al *AgentLoop) runLLMIteration(
|
||||||
return finalContent, iteration, nil
|
return finalContent, iteration, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toolExecResult holds results from executeToolCalls.
|
||||||
|
type toolExecResult struct {
|
||||||
|
lastBlocker string
|
||||||
|
gracefulInterrupt bool
|
||||||
|
gracefulHint string
|
||||||
|
}
|
||||||
|
|
||||||
// executeToolCalls runs each tool call sequentially, publishes results,
|
// executeToolCalls runs each tool call sequentially, publishes results,
|
||||||
// and returns the last blocker (error content) for reminder injection.
|
// and returns the last blocker (error content) for reminder injection.
|
||||||
func (al *AgentLoop) executeToolCalls(
|
func (al *AgentLoop) executeToolCalls(
|
||||||
|
|
@ -1118,12 +1208,26 @@ func (al *AgentLoop) executeToolCalls(
|
||||||
hooks iterationHooks,
|
hooks iterationHooks,
|
||||||
iteration int,
|
iteration int,
|
||||||
scope turnEventScope,
|
scope turnEventScope,
|
||||||
) string {
|
) toolExecResult {
|
||||||
var lastBlocker string
|
var result toolExecResult
|
||||||
steered := false
|
steered := false
|
||||||
|
gracefulSkip := false
|
||||||
for i, tc := range toolCalls {
|
for i, tc := range toolCalls {
|
||||||
|
// Check for graceful interrupt between tool calls
|
||||||
|
if !gracefulSkip {
|
||||||
|
if ts := al.getActiveTurnState(opts.SessionKey); ts != nil {
|
||||||
|
ts.mu.RLock()
|
||||||
|
if ts.gracefulInterrupt {
|
||||||
|
gracefulSkip = true
|
||||||
|
result.gracefulInterrupt = true
|
||||||
|
result.gracefulHint = ts.gracefulInterruptHint
|
||||||
|
}
|
||||||
|
ts.mu.RUnlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Check for pending steering messages between tool calls
|
// Check for pending steering messages between tool calls
|
||||||
if i > 0 && !steered {
|
if i > 0 && !steered && !gracefulSkip {
|
||||||
steeringMsgs := al.dequeueSteeringMessagesForScopeWithFallback(opts.SessionKey)
|
steeringMsgs := al.dequeueSteeringMessagesForScopeWithFallback(opts.SessionKey)
|
||||||
if len(steeringMsgs) > 0 {
|
if len(steeringMsgs) > 0 {
|
||||||
steered = true
|
steered = true
|
||||||
|
|
@ -1149,6 +1253,16 @@ func (al *AgentLoop) executeToolCalls(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Skip remaining tools if graceful interrupt was requested
|
||||||
|
if gracefulSkip {
|
||||||
|
*messages = append(*messages, providers.Message{
|
||||||
|
Role: "tool",
|
||||||
|
Content: "Skipped due to graceful interrupt.",
|
||||||
|
ToolCallID: tc.ID,
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
|
||||||
// Skip remaining tools if steering was injected
|
// Skip remaining tools if steering was injected
|
||||||
if steered {
|
if steered {
|
||||||
skipMeta := EventMeta{
|
skipMeta := EventMeta{
|
||||||
|
|
@ -1312,7 +1426,7 @@ func (al *AgentLoop) executeToolCalls(
|
||||||
contentForLLM = toolResult.Err.Error()
|
contentForLLM = toolResult.Err.Error()
|
||||||
}
|
}
|
||||||
if toolResult.IsError || toolResult.Err != nil {
|
if toolResult.IsError || toolResult.Err != nil {
|
||||||
lastBlocker = contentForLLM
|
result.lastBlocker = contentForLLM
|
||||||
}
|
}
|
||||||
|
|
||||||
toolResultMsg := providers.Message{
|
toolResultMsg := providers.Message{
|
||||||
|
|
@ -1323,7 +1437,21 @@ func (al *AgentLoop) executeToolCalls(
|
||||||
*messages = append(*messages, toolResultMsg)
|
*messages = append(*messages, toolResultMsg)
|
||||||
agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
|
agent.Sessions.AddFullMessage(opts.SessionKey, toolResultMsg)
|
||||||
}
|
}
|
||||||
return lastBlocker
|
|
||||||
|
// Re-check graceful interrupt after all tool execution (may have been
|
||||||
|
// set during the last tool's execution).
|
||||||
|
if !result.gracefulInterrupt {
|
||||||
|
if ts := al.getActiveTurnState(opts.SessionKey); ts != nil {
|
||||||
|
ts.mu.RLock()
|
||||||
|
if ts.gracefulInterrupt {
|
||||||
|
result.gracefulInterrupt = true
|
||||||
|
result.gracefulHint = ts.gracefulInterruptHint
|
||||||
|
}
|
||||||
|
ts.mu.RUnlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// forceTextResponse makes a final LLM call without tools when max iterations
|
// forceTextResponse makes a final LLM call without tools when max iterations
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue