feat: event-driven auto-continuation — agent runs an approved plan to completion
The root cause behind "the agent stops at the first error and doesn't recover":
provisioning executions run ASYNCHRONOUSLY (pct_create fires the SSH work in a
goroutine and returns "running" immediately), so the agent's turn ENDS before
the result exists. The agent literally isn't running when the step fails — it
can't react to a failure it never observes. The only thing that fed results
back was the operator typing "continue" after every async step: the human was
the event loop. (In the flagged 18-message session the operator typed
continue/proceed/?? eight times while the agent correctly diagnosed each failure
but couldn't advance a step on its own.)
This makes the system the event loop instead:
- migrations/017: nomos_plan_executions links each gated execution to the chat
session that started it.
- cmd/nomos: after a tool result, any "execution <uuid>" it started is linked
to the session. A background worker (continue.go) polls for those executions
reaching a terminal state and — while the agent has an open assent window (an
approved plan is in flight) — re-invokes the agent with the result
("execution X completed/failed: <result>"), so it proceeds to the next step
or diagnoses+fixes the failure, with no operator tick. Guarded against loops
(mark-continued before running) and bounded by the 30-min window.
- chatWith(): chat() variant that injects the finished-execution note after
replayed history without persisting a fake user turn.
- DecideApproval: approving a step by ANY route (button or chat-assent) now
opens the assent window, so auto-continuation works regardless of how the
operator approved — previously only typing "go ahead" opened it.
- SOUL: the agent is told it will be auto-re-invoked when async steps finish —
don't poll get_execution_status, don't wait for "continue"; end the turn and
keep going step by step until the goal is verified or a genuine blocker.
This is the root fix, not another per-command patch: you can't enumerate every
failure of an unbounded action space, but you can give the agent a loop that
observes each result and adapts — because "do anything" always includes "the
first attempt failed."
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -154,6 +154,16 @@ type agentEvent struct {
|
||||
}
|
||||
|
||||
func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(agentEvent)) {
|
||||
a.chatWith(ctx, sessionID, message, "", emit)
|
||||
}
|
||||
|
||||
// chatWith is chat() with an optional system-injected note appended after the
|
||||
// replayed history. The auto-continuation worker uses it to resume a session
|
||||
// with a finished execution's result ("execution X completed: … — continue the
|
||||
// plan") without persisting a fake user turn. message is normally the new user
|
||||
// message; for a worker continuation it is empty and systemInject carries the
|
||||
// note.
|
||||
func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject string, emit func(agentEvent)) {
|
||||
correlationID := uuid.New().String()
|
||||
|
||||
tools, err := a.buildTools()
|
||||
@@ -244,6 +254,12 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
|
||||
a.openAssentWindow(ctx)
|
||||
}
|
||||
|
||||
// Worker continuation: append the finished-execution note so the model
|
||||
// sees the result and decides the next step (proceed / recover / done).
|
||||
if systemInject != "" {
|
||||
messages = append(messages, openai.SystemMessage(systemInject))
|
||||
}
|
||||
|
||||
for i := 0; i < maxIterations; i++ {
|
||||
params := openai.ChatCompletionNewParams{
|
||||
Model: openai.ChatModel(a.model),
|
||||
@@ -352,6 +368,15 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
|
||||
resultJSON, _ := json.Marshal(result)
|
||||
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, inputStr, string(resultJSON), elapsed, true, correlationID)
|
||||
|
||||
// Link any execution this tool queued/started back to this
|
||||
// session, so the auto-continuation worker can feed its result
|
||||
// back here when it finishes (see cmd/nomos/continue.go). Async
|
||||
// executions (pct_create, apt_upgrade) are the ones that matter —
|
||||
// their result lands after this turn ends.
|
||||
for _, execID := range extractExecutionIDs(string(resultJSON)) {
|
||||
a.store.linkExecution(ctx, execID, sessionID)
|
||||
}
|
||||
|
||||
emit(agentEvent{
|
||||
Type: "tool_result",
|
||||
Data: map[string]any{"name": tc.Function.Name, "result": result, "id": tc.ID},
|
||||
|
||||
Reference in New Issue
Block a user