feat: knowledge write-back (upsert_knowledge) + proactive outcome reporting
From the last (successful) TypeType deploy session, two gaps the operator hit:
1. Knowledge write-back — the missing half of the loop.
The agent could read the knowledge base (search_knowledge/get_entity_knowledge)
but had no way to WRITE it, so everything it learned (the Dragonfly memlock
rlimit gotcha, the NAT-hairpin DNS issue, etc.) lived only in an ephemeral
chat message and was lost — the system could never actually "get better."
This is the `upsert_knowledge` MCP tool the 2026-07-08 gaps plan called for.
- internal/mcp/server.go: upsert_knowledge(title, content, about?, tags?,
kind?) writes a document/investigation/runbook entity + knowledge_entities
row (search column is generated), upserts by slug so re-titling updates in
place, and optionally links it to the entity it's about so
get_entity_knowledge surfaces it there.
- SOUL.md: capture non-obvious findings/deploys/gotchas as part of finishing
work, not only when asked "what did we learn".
2. "I had to ask for status multiple times."
The clearest cause: a long working turn (64 tool calls) that exhausted the
iteration cap ended with a bare "max iterations reached without final
answer" — a dead end that forced the operator to ask what happened.
- cmd/nomos/agent.go: on exhaustion, make one final no-tools LLM call
(finalSummary) asking for a status report — what was accomplished, current
state, what remains — so the turn always ends with a real outcome.
- maxIterations 25 -> 40 (the decomposed per-step pct_create flow legitimately
needs more steps).
- SOUL.md: always end a turn with a clear outcome; never end silently or on a
bare tool call — the operator can't see the tools working and reads silence
as "nothing happened".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -17,9 +17,11 @@ import (
|
||||
)
|
||||
|
||||
// maxIterations bounds one chat turn's tool-calling loop. Provisioning a
|
||||
// service is a long chain (research → plan → request_execution → status), so
|
||||
// 15 was too tight and turns died with "max iterations reached" mid-deploy.
|
||||
const maxIterations = 25
|
||||
// service is a long chain (research → plan → request_execution → per-step
|
||||
// install/verify run calls), so this must be generous; a full deploy with the
|
||||
// decomposed pct_create flow can legitimately need many steps. On exhaustion
|
||||
// the loop now produces a real summary (finalSummary) rather than a dead end.
|
||||
const maxIterations = 40
|
||||
const maxLLMRetries = 1
|
||||
|
||||
var refusalDenylist = []string{
|
||||
@@ -401,7 +403,17 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
}
|
||||
}
|
||||
|
||||
emit(agentEvent{Type: "text", Data: "Agent loop: max iterations reached without final answer.", SessionID: sessionID})
|
||||
// Hitting the step limit used to end the turn with a bare "max iterations
|
||||
// reached without final answer" — a dead end that made the operator ask
|
||||
// "status?" to find out what actually happened after a long working turn.
|
||||
// Instead, spend one final call asking the model to summarize what it did
|
||||
// and the current state, so the turn always ends with a real report.
|
||||
messages = append(messages, openai.SystemMessage("[System: you've reached the step limit for this turn. STOP calling tools now and write a concise status report: what you accomplished, the current state of the goal, anything that failed, and what remains. This is what the operator sees.]"))
|
||||
summary := a.finalSummary(ctx, messages)
|
||||
if summary == "" {
|
||||
summary = "I hit this turn's step limit while working. I've done a lot but couldn't wrap up cleanly — ask me for a status update and I'll summarize the current state."
|
||||
}
|
||||
emit(agentEvent{Type: "text", Data: summary, SessionID: sessionID})
|
||||
emit(agentEvent{Type: "done", Data: map[string]any{
|
||||
"session_id": sessionID,
|
||||
"correlation_id": correlationID,
|
||||
@@ -409,6 +421,22 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
||||
}, SessionID: sessionID})
|
||||
}
|
||||
|
||||
// finalSummary makes one non-tool LLM call to turn an exhausted tool-loop into
|
||||
// a real status report instead of a dead-end message. Best-effort: empty on
|
||||
// any error, and the caller has a fallback.
|
||||
func (a *agent) finalSummary(ctx context.Context, messages []openai.ChatCompletionMessageParamUnion) string {
|
||||
params := openai.ChatCompletionNewParams{
|
||||
Model: openai.ChatModel(a.model),
|
||||
Messages: messages,
|
||||
// No Tools: force a text answer.
|
||||
}
|
||||
resp, err := a.provider.Chat.Completions.New(ctx, params, a.reqOpts...)
|
||||
if err != nil || len(resp.Choices) == 0 {
|
||||
return ""
|
||||
}
|
||||
return resp.Choices[0].Message.Content
|
||||
}
|
||||
|
||||
// extractText pulls the "text" field from a persisted message's JSONB content.
|
||||
func extractText(content json.RawMessage) string {
|
||||
var m struct {
|
||||
|
||||
Reference in New Issue
Block a user