feat(agent): plan-first gate, iterative follow-ups, reasoning persistence
P1 plan-first: run handler refuses without propose_plan (structural gate,
not SOUL.md prose). Plan window decoupled from set_goal — config_mutation
auto-run only on operator approval (assent window). Closes the approval-free
config_mutation hole confirmed in session d0d562e0.
P2 iteration: reopenSession flips terminal→executing, marks prior plan steps
replaced, clears outcome. proposePlan excludes replaced from in-flight check,
bumps generation. A follow-up on a completed session starts a new sub-task
with a fresh plan — no more errPlanInFlight dead end.
P3 reasoning: accumulate per-iteration text into the persisted row instead
of overwriting with the last text event. Reload shows intermediate thinking,
not just the final summary.
P4 read-only allowlist: add find, tree, locate, systemctl list-timers/
list-unit-files/show, timedatectl, hostnamectl, systemd-analyze, rclone
ls/lsl/md5sum/check/cryptcheck. Fixes the find misclassification from
d0d562e0.
P5 eval harness: new assertion kinds (proposes_plan, plan_before_run,
plan_generations), multi-turn followups, fetch /sessions/{id}/plan. Four
manifests under evals/.
P6 SOUL.md: strip degenerate-case carve-out, add ITERATE step, update
set_goal guidance.
VERSION 0.6.0 → 0.7.0
This commit is contained in:
@@ -420,6 +420,18 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
return
|
||||
}
|
||||
|
||||
// P3: persist intermediate reasoning. When the model produces text
|
||||
// AND tool calls in the same iteration, the text is its reasoning
|
||||
// before the tool calls — the operator saw it live via text_delta,
|
||||
// but without emitting it as a `text` event here, the persist layer
|
||||
// (main.go/continue.go) never captures it and a reload shows only
|
||||
// the final summary + a flat tool-call list, not the thinking that
|
||||
// led to each step. Emitting it lets the persist layer accumulate
|
||||
// per-iteration reasoning into the row's text field.
|
||||
if strings.TrimSpace(msg.Content) != "" {
|
||||
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
|
||||
}
|
||||
|
||||
slog.Info("nomos: tool calls", "count", len(msg.ToolCalls), "iter", i+1, "correlation", correlationID)
|
||||
|
||||
messages = append(messages, msg.ToParam())
|
||||
|
||||
@@ -247,6 +247,10 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) {
|
||||
}
|
||||
}
|
||||
toolCalls, finalText, errText = nil, "", ""
|
||||
// P3: accumulate per-iteration reasoning instead of overwriting
|
||||
// (same fix as main.go's chat handler). Without this, a resumed
|
||||
// turn's intermediate thinking is lost on reload.
|
||||
var textParts []string
|
||||
emit := func(ev agentEvent) {
|
||||
if ev.Type == "tool_use" || ev.Type == "tool_result" {
|
||||
if m, ok := ev.Data.(map[string]any); ok {
|
||||
@@ -272,7 +276,11 @@ func (a *agent) resumeSession(ctx context.Context, sessionID, note string) {
|
||||
persist() // live: a poller sees this step land within seconds
|
||||
}
|
||||
if ev.Type == "text" {
|
||||
finalText, _ = ev.Data.(string)
|
||||
if t, ok := ev.Data.(string); ok && t != "" {
|
||||
textParts = append(textParts, t)
|
||||
finalText = strings.Join(textParts, "\n\n")
|
||||
persist()
|
||||
}
|
||||
}
|
||||
if ev.Type == "error" {
|
||||
errText, _ = ev.Data.(string)
|
||||
|
||||
@@ -125,8 +125,8 @@ func runConversation(ctx context.Context, gateway string, c conversation, timeou
|
||||
}
|
||||
|
||||
// Send followup if any.
|
||||
if c.Followup != "" {
|
||||
if _, err := sendChat(ctx, gateway, sid, c.Followup); err != nil {
|
||||
for _, fu := range c.followups() {
|
||||
if _, err := sendChat(ctx, gateway, sid, fu); err != nil {
|
||||
res.Assertions = []assertionResult{{Name: "send_followup", Passed: false, Detail: err.Error()}}
|
||||
res.Duration = time.Since(start)
|
||||
return res
|
||||
@@ -246,6 +246,19 @@ type transcript struct {
|
||||
ToolCalls []map[string]any `json:"tool_calls"`
|
||||
} `json:"content"`
|
||||
} `json:"messages"`
|
||||
// PlanSteps is fetched from /sessions/{id}/plan (P5 plan_generations
|
||||
// assertion). Each step carries a `generation` int; distinctGenerations
|
||||
// counts the unique values. nil when the endpoint returned no plan
|
||||
// (e.g. a pure-DB Q&A with no propose_plan call).
|
||||
PlanSteps []planStep `json:"steps"`
|
||||
}
|
||||
|
||||
// planStep is one step from /sessions/{id}/plan, carrying only the fields the
|
||||
// eval needs: the generation number (P2 iteration counter).
|
||||
type planStep struct {
|
||||
Generation int `json:"generation"`
|
||||
Status string `json:"status"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
func (t transcript) toolCallCount() int {
|
||||
@@ -268,6 +281,17 @@ func (t transcript) toolNames() []string {
|
||||
return names
|
||||
}
|
||||
|
||||
// distinctGenerations counts unique plan generation values across all plan
|
||||
// steps. Used by the `plan_generations` assertion (P2 iteration). Returns 0
|
||||
// when there are no plan steps (no propose_plan was called).
|
||||
func (t transcript) distinctGenerations() int {
|
||||
seen := map[int]bool{}
|
||||
for _, s := range t.PlanSteps {
|
||||
seen[s.Generation] = true
|
||||
}
|
||||
return len(seen)
|
||||
}
|
||||
|
||||
type sessionState struct {
|
||||
ID string `json:"id"`
|
||||
Status string `json:"status"`
|
||||
@@ -277,7 +301,8 @@ type sessionState struct {
|
||||
|
||||
// fetchTranscript fetches the messages from /sessions/{id} (which returns
|
||||
// only session_id + messages) and the session metadata from /sessions
|
||||
// (which returns status/outcome/last_active_at for each session).
|
||||
// (which returns status/outcome/last_active_at for each session). P5 also
|
||||
// fetches /sessions/{id}/plan for the plan_generations assertion.
|
||||
func fetchTranscript(ctx context.Context, gateway, sid string) (transcript, sessionState, error) {
|
||||
var t transcript
|
||||
resp, err := http.Get(gateway + "/sessions/" + sid)
|
||||
@@ -292,6 +317,16 @@ func fetchTranscript(ctx context.Context, gateway, sid string) (transcript, sess
|
||||
if err := json.Unmarshal(b, &t); err != nil {
|
||||
return t, sessionState{}, err
|
||||
}
|
||||
// Fetch the plan (steps with generation numbers) for the
|
||||
// plan_generations assertion. A 404 or empty response is fine — a
|
||||
// pure-DB Q&A with no propose_plan has no plan.
|
||||
if planResp, perr := http.Get(gateway + "/sessions/" + sid + "/plan"); perr == nil {
|
||||
if planResp.StatusCode == 200 {
|
||||
pb, _ := io.ReadAll(planResp.Body)
|
||||
_ = json.Unmarshal(pb, &t) // fills t.PlanSteps via "steps" field
|
||||
}
|
||||
planResp.Body.Close()
|
||||
}
|
||||
// The detail endpoint doesn't return status/outcome — fetch from the
|
||||
// sessions list and find the matching id.
|
||||
s, err := fetchSessionMeta(ctx, gateway, sid)
|
||||
|
||||
@@ -11,10 +11,24 @@ import (
|
||||
type conversation struct {
|
||||
Name string `yaml:"name"`
|
||||
Prompt string `yaml:"prompt"`
|
||||
Followup string `yaml:"followup"`
|
||||
Followup string `yaml:"followup"` // backward compat: single followup
|
||||
Followups []string `yaml:"followups"` // P5: multi-turn followups
|
||||
Assertions []assertion `yaml:"assertions"`
|
||||
}
|
||||
|
||||
// followups returns the full list of follow-up messages, supporting both
|
||||
// the single `followup` field (backward compat) and the multi-turn
|
||||
// `followups` list.
|
||||
func (c conversation) followups() []string {
|
||||
if len(c.Followups) > 0 {
|
||||
return c.Followups
|
||||
}
|
||||
if c.Followup != "" {
|
||||
return []string{c.Followup}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// assertion is one check against the final transcript. The `kind` field
|
||||
// selects the scorer; the rest are scorer-specific parameters.
|
||||
//
|
||||
@@ -23,13 +37,15 @@ type conversation struct {
|
||||
// completes — session status reached done/failed (not stuck executing)
|
||||
// outcome_is — session outcome == value (success/failure/partial)
|
||||
// no_propose_plan — propose_plan was never called
|
||||
// proposes_plan — propose_plan called >= 1 time (plan-always model; P1)
|
||||
// proposes_plan_once — propose_plan was called exactly once
|
||||
// no_duplicate_proposal — propose_plan called at most once
|
||||
// plan_before_run — the first `run` call comes after the first `propose_plan` (P1 ordering gate)
|
||||
// plan_generations — the persisted plan has exactly `value` distinct generations (P2 iteration: 1 = single, 2 = one followup)
|
||||
// writes_back — update_entity_attributes or create_relationship was called
|
||||
// max_tool_calls — total tool calls <= value
|
||||
// max_run_calls — total `run` calls <= value
|
||||
// no_run — `run` was never called
|
||||
// no_rerun — `run` was NOT called after the followup turn (if any)
|
||||
// calls_tool — the named tool appears in the transcript
|
||||
// plan_step_count — the plan has exactly `value` steps
|
||||
// no_duplicate_complete — complete_task called at most once
|
||||
@@ -88,6 +104,14 @@ func scoreOne(a assertion, t transcript, s sessionState) (bool, string) {
|
||||
}
|
||||
return false, fmt.Sprintf("propose_plan called %d time(s)", n)
|
||||
|
||||
case "proposes_plan":
|
||||
// P1 plan-always: propose_plan called >= 1 time.
|
||||
n := countTool(tools, "propose_plan")
|
||||
if n >= 1 {
|
||||
return true, fmt.Sprintf("propose_plan called %d time(s)", n)
|
||||
}
|
||||
return false, "propose_plan never called (plan-always requires >= 1)"
|
||||
|
||||
case "proposes_plan_once":
|
||||
n := countTool(tools, "propose_plan")
|
||||
if n == 1 {
|
||||
@@ -102,6 +126,44 @@ func scoreOne(a assertion, t transcript, s sessionState) (bool, string) {
|
||||
}
|
||||
return false, fmt.Sprintf("propose_plan called %d time(s), want <= 1", n)
|
||||
|
||||
case "plan_before_run":
|
||||
// P1 ordering gate: the first `run` call's global index in the
|
||||
// transcript is strictly greater than the first `propose_plan`
|
||||
// index. Both indices are over the flat tool-call list (across all
|
||||
// messages, in order).
|
||||
planIdx, runIdx := -1, -1
|
||||
for i, name := range tools {
|
||||
if name == "propose_plan" && planIdx == -1 {
|
||||
planIdx = i
|
||||
}
|
||||
if name == "run" && runIdx == -1 {
|
||||
runIdx = i
|
||||
}
|
||||
}
|
||||
if runIdx == -1 {
|
||||
return true, "run never called (ordering trivially satisfied)"
|
||||
}
|
||||
if planIdx == -1 {
|
||||
return false, "run called but propose_plan never called"
|
||||
}
|
||||
if planIdx < runIdx {
|
||||
return true, fmt.Sprintf("propose_plan at index %d before run at index %d", planIdx, runIdx)
|
||||
}
|
||||
return false, fmt.Sprintf("run at index %d before propose_plan at index %d", runIdx, planIdx)
|
||||
|
||||
case "plan_generations":
|
||||
// P2 iteration: counts distinct `generation` values in
|
||||
// session_plan_steps. 1 = single sub-task, 2 = one follow-up
|
||||
// sub-task, etc. Requires the plan endpoint to return generation
|
||||
// values; the eval fetches /sessions/{id}/plan and passes it via
|
||||
// the transcript's PlanSteps field.
|
||||
want := toInt(a.Value)
|
||||
gens := t.distinctGenerations()
|
||||
if gens == want {
|
||||
return true, fmt.Sprintf("%d plan generation(s)", gens)
|
||||
}
|
||||
return false, fmt.Sprintf("%d plan generation(s), want %d", gens, want)
|
||||
|
||||
case "writes_back":
|
||||
n := countTool(tools, "update_entity_attributes") + countTool(tools, "create_relationship")
|
||||
if n > 0 {
|
||||
|
||||
@@ -224,6 +224,16 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
||||
sessionID = sess.ID
|
||||
}
|
||||
} else {
|
||||
// P2 iteration: if the operator sends a follow-up on a session
|
||||
// that already reached a terminal state (done/failed), reopen it
|
||||
// so a new sub-task can be framed (set_goal → propose_plan →
|
||||
// execute). reopenSession marks the prior plan's steps as
|
||||
// `replaced` (proposePlan ignores those) and clears outcome/
|
||||
// summary. Without this, propose_plan refuses the follow-up with
|
||||
// errPlanInFlight because the prior steps are all `done`. If the
|
||||
// session is still active, reopen is a no-op — the follow-up is
|
||||
// just a continuation of in-flight work.
|
||||
st.reopenSession(pctx, sessionID)
|
||||
st.touchSession(pctx, sessionID)
|
||||
}
|
||||
|
||||
@@ -243,6 +253,13 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
||||
sseEvent(w, flusher, agentEvent{Type: "session", Data: sessionID, SessionID: sessionID})
|
||||
|
||||
toolCalls := []map[string]any{}
|
||||
// P3: accumulate per-iteration reasoning instead of overwriting with
|
||||
// the final `text` event. The agent loop emits a `text` event for each
|
||||
// LLM iteration that produced text (intermediate reasoning before tool
|
||||
// calls + the final answer). Without accumulation, only the last `text`
|
||||
// survives in the persisted row — a reload shows the final summary but
|
||||
// not the thinking that led to each tool call.
|
||||
var textParts []string
|
||||
var finalText string
|
||||
|
||||
// Incremental persistence, mirroring resumeSession's existing
|
||||
@@ -292,7 +309,15 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
||||
persist() // live: survives even if the client disconnects right after
|
||||
}
|
||||
if ev.Type == "text" {
|
||||
finalText, _ = ev.Data.(string)
|
||||
// P3: accumulate. Each `text` event is one iteration's reasoning
|
||||
// (or the final answer). Join with newlines so the persisted row
|
||||
// reads as the full transcript of what the agent said, not just
|
||||
// the last thing.
|
||||
if t, ok := ev.Data.(string); ok && t != "" {
|
||||
textParts = append(textParts, t)
|
||||
finalText = strings.Join(textParts, "\n\n")
|
||||
persist()
|
||||
}
|
||||
}
|
||||
sseEvent(w, flusher, ev)
|
||||
})
|
||||
|
||||
@@ -466,18 +466,44 @@ func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
// openPlanWindow records a plan window so config_mutation `run` calls in
|
||||
// this session auto-execute without per-action approval. The operator
|
||||
// approves the plan (propose_plan), not each individual command. Opened on
|
||||
// set_goal and stays active until the task is completed or the session ends.
|
||||
func (s *store) openPlanWindow(ctx context.Context, sessionID string) {
|
||||
if s == nil || sessionID == "" {
|
||||
return
|
||||
// reopenSession flips a terminal (done/failed) session back to `executing`
|
||||
// so a follow-up message can start a new sub-task — the iteration path
|
||||
// (P2, 2026-07-15). Without this, a completed session stays `done` forever
|
||||
// and propose_plan refuses the new sub-task with errPlanInFlight because the
|
||||
// prior plan's steps are all `done` (status <> 'pending'). reopenSession:
|
||||
//
|
||||
// 1. Marks all existing session_plan_steps as `replaced` (a status already
|
||||
// recognized by updatePlanStep's stamp switch). The rows are KEPT — the
|
||||
// generation column preserves which plan they belonged to, and the
|
||||
// audit trail survives. proposePlan's anyStarted check excludes
|
||||
// `replaced` (see proposePlan), so the next propose_plan takes the
|
||||
// fresh-generation path rather than being refused with errPlanInFlight.
|
||||
// 2. Clears outcome/summary so the panel doesn't show the old result.
|
||||
// 3. Stamps last_active_at.
|
||||
//
|
||||
// Returns true if the session was actually reopened (was terminal), false if
|
||||
// it was already active (no-op — the follow-up is just a continuation).
|
||||
func (s *store) reopenSession(ctx context.Context, sessionID string) bool {
|
||||
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||
return false
|
||||
}
|
||||
var currentStatus string
|
||||
if err := s.pool.QueryRow(ctx,
|
||||
`SELECT status FROM agent_sessions WHERE id = $1`, sessionID).Scan(¤tStatus); err != nil {
|
||||
return false
|
||||
}
|
||||
if currentStatus != "done" && currentStatus != "failed" {
|
||||
return false
|
||||
}
|
||||
s.pool.Exec(ctx,
|
||||
`INSERT INTO autonomy_settings (key, value) VALUES ($1, 'active')
|
||||
ON CONFLICT (key) DO UPDATE SET value = 'active'`,
|
||||
"nomos:plan:"+sessionID)
|
||||
`UPDATE session_plan_steps SET status = 'replaced', finished_at = COALESCE(finished_at, now()) WHERE session_id = $1 AND status <> 'replaced'`,
|
||||
sessionID)
|
||||
s.pool.Exec(ctx,
|
||||
`UPDATE agent_sessions SET status = 'executing', outcome = NULL, summary = NULL, last_active_at = now() WHERE id = $1`,
|
||||
sessionID)
|
||||
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.reopened", s.taskEntityPtr(ctx, sessionID),
|
||||
"info", "nomos", sessionID, map[string]any{"prior_status": currentStatus})
|
||||
return true
|
||||
}
|
||||
|
||||
// planStepInput is one step as the agent proposes it.
|
||||
@@ -514,8 +540,13 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
|
||||
|
||||
var startSeq int
|
||||
var anyStarted bool
|
||||
// `replaced` steps (from a prior plan generation superseded by a
|
||||
// follow-up sub-task — see reopenSession) are excluded: they prove a
|
||||
// prior plan was completed and superseded, not that a plan is in flight.
|
||||
// Without this exclusion, reopenSession's `replaced` marking would be
|
||||
// useless — propose_plan would still refuse on the follow-up.
|
||||
if err := tx.QueryRow(ctx, `
|
||||
SELECT COALESCE(max(seq), 0), COALESCE(bool_or(status <> 'pending'), false)
|
||||
SELECT COALESCE(max(seq), 0), COALESCE(bool_or(status NOT IN ('pending', 'replaced')), false)
|
||||
FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&startSeq, &anyStarted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -525,13 +556,20 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
|
||||
// update_plan_step + run. The caller surfaces a directive.
|
||||
return nil, errPlanInFlight
|
||||
}
|
||||
// Fresh/revise: delete any prior pending steps and start a new
|
||||
// generation. The DELETE covers the genuine pre-execution revision
|
||||
// case (operator asked to revise before any step started).
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM session_plan_steps WHERE session_id = $1`, sessionID); err != nil {
|
||||
// Fresh/revise: delete any prior PENDING steps (the genuine pre-execution
|
||||
// revision case — operator asked to revise before any step started).
|
||||
// `replaced` steps (from a prior completed plan superseded by a
|
||||
// follow-up — see reopenSession) are KEPT so the generation counter
|
||||
// (MAX(generation)+1 below) and the plan_generations eval assertion
|
||||
// can see across iterations. The anyStarted check above already
|
||||
// excludes `replaced`, so they don't block the fresh proposal.
|
||||
if _, err := tx.Exec(ctx, `DELETE FROM session_plan_steps WHERE session_id = $1 AND status = 'pending'`, sessionID); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
startSeq = 0
|
||||
// startSeq keeps the max(seq) from the query above: if `replaced` rows
|
||||
// exist (prior generation), the new generation's steps start after them
|
||||
// (no seq collisions across generations). If no rows exist (first plan
|
||||
// or a full DELETE), startSeq is 0 and the first step is seq 1.
|
||||
|
||||
// Resolve the generation number for this plan. Generation 1 is the
|
||||
// initial plan; a genuine revise (which currently goes through the same
|
||||
@@ -579,15 +617,6 @@ func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planS
|
||||
// the panel to replace its list with these steps.
|
||||
_ = observability.Event(ctx, sqlcgen.New(s.pool), "plan.proposed", s.taskEntityPtr(ctx, sessionID),
|
||||
"info", "nomos", sessionID, map[string]any{"steps": out, "appended": false, "generation": nextGen})
|
||||
// Record a plan-proposed window so the `run` handler knows a plan is
|
||||
// pending approval and can skip per-action approval for config_mutation
|
||||
// commands within the plan. Transitions to 'active' when the operator
|
||||
// approves (chat-assent or button). The window key is session-scoped;
|
||||
// one agent serves all sessions on this nomos instance.
|
||||
s.pool.Exec(ctx,
|
||||
`INSERT INTO autonomy_settings (key, value) VALUES ($1, 'active')
|
||||
ON CONFLICT (key) DO UPDATE SET value = 'active'`,
|
||||
"nomos:plan:"+sessionID)
|
||||
return out, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -176,12 +176,16 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
||||
if err := a.store.setGoal(ctx, sessionID, goal); err != nil {
|
||||
return fmt.Sprintf("error setting goal: %v", err), true
|
||||
}
|
||||
// Open the plan window immediately — the goal IS the start of a
|
||||
// plan. Config_mutation commands in this session auto-execute
|
||||
// without per-action approval. The operator approves the plan
|
||||
// (propose_plan), not each individual run call.
|
||||
a.store.openPlanWindow(ctx, sessionID)
|
||||
return "Goal set: " + goal + ". NEXT: pre-plan (read-only tools only — search_knowledge, get_entity, list_lxcs, get_relations). Then propose_plan. Do not call run.", true
|
||||
// P1: the plan window is NOT opened here. Opening it on set_goal
|
||||
// meant any config_mutation `run` auto-executed with zero operator
|
||||
// approval, before a plan was even proposed (let alone approved) —
|
||||
// a safety regression confirmed live in session d0d562e0. The
|
||||
// window is now opened only when the operator approves a plan
|
||||
// (chat-assent grant or explicit approval in agent.go), which is
|
||||
// what the SOUL.md "approve the plan, not each step" model actually
|
||||
// describes. set_goal records the goal + flips status to executing
|
||||
// and nothing more.
|
||||
return "Goal set: " + goal + ". NEXT: pre-plan with read-only tools (search_knowledge, get_entity, list_lxcs, get_relations), then propose_plan (mandatory — even read-only tasks need a one-step plan; the run handler refuses without one). Do not call run before propose_plan.", true
|
||||
|
||||
case "propose_plan":
|
||||
raw, _ := args["steps"].([]any)
|
||||
@@ -237,7 +241,7 @@ func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args
|
||||
// the "plan added twice" sidebar drift the operator
|
||||
// reported: instead of appending (which duplicated) or
|
||||
// wiping (which lost progress), we refuse and direct.
|
||||
return "Plan already in flight — refusing duplicate proposal. Steps exist and at least one has started (running/done/...). To advance: call update_plan_step(seq=K, status=\"running\") then run(...) for step K's target, then update_plan_step(seq=K, status=\"done\"). Do NOT call propose_plan again. Re-propose only if the operator explicitly asks you to revise the whole plan, and say so in your reply before calling it.", true
|
||||
return "Plan already in flight — refusing duplicate proposal. Steps exist and at least one has started (running/done/...). To advance: call update_plan_step(seq=K, status=\"running\") then run(...) for step K's target, then update_plan_step(seq=K, status=\"done\"). Do not call propose_plan again. Re-propose only if the operator explicitly asks you to revise the whole plan (the session is reopened on a follow-up — prior steps are marked `replaced` and a fresh generation is started), and say so in your reply before calling it.", true
|
||||
}
|
||||
return fmt.Sprintf("error proposing plan: %v", err), true
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user