Compare commits
33 Commits
claude/oik
...
0ed171507f
| Author | SHA1 | Date | |
|---|---|---|---|
| 0ed171507f | |||
| e3cbaee534 | |||
| de126daf43 | |||
| c8b479d565 | |||
| 075ff93792 | |||
| 3b9c75fa3f | |||
| e3850f6820 | |||
| fb4c76ba82 | |||
| b72267bd72 | |||
| 11c18e8956 | |||
| 6d4f6de676 | |||
| c3901641d1 | |||
| 76f76308cc | |||
| 926969a03f | |||
| c5ffaec85b | |||
| 3919ec37d7 | |||
| df393152f6 | |||
| a4ea542f3e | |||
| 6a8fb435ad | |||
| 9131559ebd | |||
| 9ef1ba3702 | |||
| 6932eb5eed | |||
| e30813a43d | |||
| 5384499903 | |||
| 991e7d0900 | |||
| 413bf54daf | |||
| 014e5c74e0 | |||
| be3ce761d4 | |||
| 532310bb4b | |||
| 3dba2e550a | |||
| 72e9fe534e | |||
| eed6e3b1c5 | |||
| ef5a92269b |
@@ -24,6 +24,20 @@ import (
|
|||||||
const maxIterations = 40
|
const maxIterations = 40
|
||||||
const maxLLMRetries = 1
|
const maxLLMRetries = 1
|
||||||
|
|
||||||
|
// historyWindowSize bounds how many of a session's most recent persisted
|
||||||
|
// messages are replayed into the LLM's context on each turn — see
|
||||||
|
// store.go's getRecentMessages for why this exists (fix A2 of
|
||||||
|
// plans/2026-07-11-nomos-agent-code-review.md: unbounded history replay was
|
||||||
|
// a real, observed-in-production cost/latency/eventual-context-limit risk).
|
||||||
|
// 30 is a fixed-window choice, not token-budget-aware: simplest option that
|
||||||
|
// still keeps roughly the current task's working context, at the cost of
|
||||||
|
// occasionally dropping something a very long task still needed — the
|
||||||
|
// system note injected when truncation happens tells the model to check
|
||||||
|
// upsert_knowledge/search_knowledge rather than assume something didn't
|
||||||
|
// happen. A token-aware trim or LLM-summarize-on-drop are documented
|
||||||
|
// stretch options if a fixed window proves insufficient in practice.
|
||||||
|
const historyWindowSize = 30
|
||||||
|
|
||||||
var refusalDenylist = []string{
|
var refusalDenylist = []string{
|
||||||
"我没有相关信息",
|
"我没有相关信息",
|
||||||
"您可以尝试问我其它问题",
|
"您可以尝试问我其它问题",
|
||||||
@@ -33,7 +47,7 @@ var refusalDenylist = []string{
|
|||||||
}
|
}
|
||||||
|
|
||||||
type agent struct {
|
type agent struct {
|
||||||
client *mcpClient
|
clients *mcpClientPool // one MCP client PER SESSION, not shared — see mcpClientPool's doc comment
|
||||||
system string
|
system string
|
||||||
provider *openai.Client
|
provider *openai.Client
|
||||||
model string
|
model string
|
||||||
@@ -44,7 +58,7 @@ type agent struct {
|
|||||||
httpClient *http.Client
|
httpClient *http.Client
|
||||||
}
|
}
|
||||||
|
|
||||||
func newAgent(ctx context.Context, mcpClient *mcpClient, st *store, agentSlug string) (*agent, error) {
|
func newAgent(ctx context.Context, clients *mcpClientPool, st *store, agentSlug string) (*agent, error) {
|
||||||
system := loadSoul()
|
system := loadSoul()
|
||||||
apiKey := os.Getenv("OPENROUTER_API_KEY")
|
apiKey := os.Getenv("OPENROUTER_API_KEY")
|
||||||
model := os.Getenv("NOMOS_MODEL")
|
model := os.Getenv("NOMOS_MODEL")
|
||||||
@@ -92,7 +106,7 @@ func newAgent(ctx context.Context, mcpClient *mcpClient, st *store, agentSlug st
|
|||||||
}
|
}
|
||||||
|
|
||||||
return &agent{
|
return &agent{
|
||||||
client: mcpClient,
|
clients: clients,
|
||||||
system: system,
|
system: system,
|
||||||
provider: &provider,
|
provider: &provider,
|
||||||
model: model,
|
model: model,
|
||||||
@@ -125,12 +139,15 @@ const assentWindowDuration = 30 * time.Minute
|
|||||||
|
|
||||||
// openAssentWindow records an active assent window in autonomy_settings so
|
// openAssentWindow records an active assent window in autonomy_settings so
|
||||||
// the MCP run tool (separate process) can check it before requiring approval
|
// the MCP run tool (separate process) can check it before requiring approval
|
||||||
// for config_mutation commands. Key is scoped to this agent's UUID.
|
// for config_mutation commands. Key is scoped to this agent's UUID AND this
|
||||||
func (a *agent) openAssentWindow(ctx context.Context) {
|
// session/task — see store.go's assentWindowActive for why: without the
|
||||||
if a.store == nil || a.store.pool == nil || a.agentID == uuid.Nil {
|
// session dimension, approving one task's plan would silently auto-run
|
||||||
|
// unapproved actions in any other concurrently-running task.
|
||||||
|
func (a *agent) openAssentWindow(ctx context.Context, sessionID string) {
|
||||||
|
if a.store == nil || a.store.pool == nil || a.agentID == uuid.Nil || sessionID == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
key := "assent_window.agent:" + a.agentID.String()
|
key := assentWindowKey(a.agentID, sessionID)
|
||||||
expires := time.Now().Add(assentWindowDuration).UTC().Format(time.RFC3339)
|
expires := time.Now().Add(assentWindowDuration).UTC().Format(time.RFC3339)
|
||||||
_, err := a.store.pool.Exec(ctx,
|
_, err := a.store.pool.Exec(ctx,
|
||||||
`INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
|
`INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
|
||||||
@@ -138,7 +155,7 @@ func (a *agent) openAssentWindow(ctx context.Context) {
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Warn("nomos: openAssentWindow", "error", err)
|
slog.Warn("nomos: openAssentWindow", "error", err)
|
||||||
} else {
|
} else {
|
||||||
slog.Info("nomos: assent window opened", "agent", a.agentID, "expires", expires)
|
slog.Info("nomos: assent window opened", "agent", a.agentID, "session", sessionID, "expires", expires)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -168,18 +185,36 @@ func (a *agent) chat(ctx context.Context, sessionID, message string, emit func(a
|
|||||||
func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject string, emit func(agentEvent)) {
|
func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject string, emit func(agentEvent)) {
|
||||||
correlationID := uuid.New().String()
|
correlationID := uuid.New().String()
|
||||||
|
|
||||||
tools, err := a.buildTools()
|
tools, err := a.buildTools(sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
emit(agentEvent{Type: "error", Data: fmt.Sprintf("build tools: %v", err), SessionID: sessionID})
|
emit(agentEvent{Type: "error", Data: fmt.Sprintf("build tools: %v", err), SessionID: sessionID})
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
system := a.system
|
system := a.system
|
||||||
if snapshot := a.fleetSnapshot(); snapshot != "" {
|
if snapshot := a.fleetSnapshot(sessionID); snapshot != "" {
|
||||||
system += "\n\n" + snapshot
|
system += "\n\n" + snapshot
|
||||||
}
|
}
|
||||||
messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(system)}
|
messages := []openai.ChatCompletionMessageParamUnion{openai.SystemMessage(system)}
|
||||||
history, _ := a.store.getMessages(ctx, sessionID)
|
history, truncatedHistory, _ := a.store.getRecentMessages(ctx, sessionID, historyWindowSize)
|
||||||
|
if truncatedHistory {
|
||||||
|
// Tell the model explicitly rather than silently dropping older
|
||||||
|
// turns — otherwise it might assume something wasn't done just
|
||||||
|
// because it doesn't see the turn that did it.
|
||||||
|
messages = append(messages, openai.SystemMessage(fmt.Sprintf(
|
||||||
|
"[System: this task has been running long enough that only the most recent %d turns of its history are included above your context — earlier turns happened but aren't shown. If you need to know what was already tried or found, check search_knowledge/get_entity_knowledge (if you recorded it) rather than assuming it didn't happen.]",
|
||||||
|
historyWindowSize)))
|
||||||
|
}
|
||||||
|
// sawSetGoal / sawCompleteTask track whether this session has EVER framed
|
||||||
|
// itself as a structured task (set_goal) or already reached a terminal
|
||||||
|
// state (complete_task) — across both replayed history and this turn's
|
||||||
|
// own tool calls (updated again below as they happen live). Used by the
|
||||||
|
// end-of-turn safety net (plans/2026-07-11-task-completion-safety-net.md,
|
||||||
|
// fix 1): most sessions are a single trivial Q&A exchange that answers in
|
||||||
|
// text and never calls either tool, leaving agent_sessions.status stuck
|
||||||
|
// at its creation-time default forever. If a session never framed itself
|
||||||
|
// as a task, its first plain-text turn-end IS the task ending.
|
||||||
|
var sawSetGoal, sawCompleteTask bool
|
||||||
var lastAssistantCalls []persistedCall
|
var lastAssistantCalls []persistedCall
|
||||||
for _, m := range history {
|
for _, m := range history {
|
||||||
text := extractText(m.Content)
|
text := extractText(m.Content)
|
||||||
@@ -191,6 +226,12 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
messages = append(messages, assistantToolCallMessage(calls))
|
messages = append(messages, assistantToolCallMessage(calls))
|
||||||
for _, c := range calls {
|
for _, c := range calls {
|
||||||
messages = append(messages, openai.ToolMessage(c.resultText(), c.id))
|
messages = append(messages, openai.ToolMessage(c.resultText(), c.id))
|
||||||
|
switch c.name {
|
||||||
|
case "set_goal":
|
||||||
|
sawSetGoal = true
|
||||||
|
case "complete_task":
|
||||||
|
sawCompleteTask = true
|
||||||
|
}
|
||||||
}
|
}
|
||||||
lastAssistantCalls = calls
|
lastAssistantCalls = calls
|
||||||
}
|
}
|
||||||
@@ -242,15 +283,15 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
if p.destructive && typedConfirm {
|
if p.destructive && typedConfirm {
|
||||||
if execUUID, perr := uuid.Parse(p.execID); perr == nil {
|
if execUUID, perr := uuid.Parse(p.execID); perr == nil {
|
||||||
if target := a.store.executionTarget(ctx, execUUID); target != "" {
|
if target := a.store.executionTarget(ctx, execUUID); target != "" {
|
||||||
a.store.openDestructiveWindow(ctx, a.agentID, target)
|
a.store.openDestructiveWindow(ctx, a.agentID, target, sessionID)
|
||||||
slog.Info("nomos: destructive window opened", "agent", a.agentID, "target", target)
|
slog.Info("nomos: destructive window opened", "agent", a.agentID, "target", target, "session", sessionID)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
if len(granted) > 0 {
|
if len(granted) > 0 {
|
||||||
a.openAssentWindow(ctx)
|
a.openAssentWindow(ctx, sessionID)
|
||||||
note := fmt.Sprintf("[System: the operator's message approved pending execution(s) %s via chat assent — they are now running. An assent window is now active for 30 minutes: config_mutation commands will auto-run without re-approval. Do not re-request or call request_execution/run again for these; check get_execution_status if you need the outcome. CONTINUE executing the full plan — do not stop and wait for 'continue' after each step. Only surface to the operator for destructive actions (need typed confirmation) or if you're genuinely stuck after trying alternatives.]", strings.Join(granted, ", "))
|
note := fmt.Sprintf("[System: the operator's message approved pending execution(s) %s via chat assent — they are now running. An assent window is now active for 30 minutes: config_mutation commands will auto-run without re-approval. Do not re-request or call request_execution/run again for these; check get_execution_status if you need the outcome. CONTINUE executing the full plan — do not stop and wait for 'continue' after each step. Only surface to the operator for destructive actions (need typed confirmation) or if you're genuinely stuck after trying alternatives.]", strings.Join(granted, ", "))
|
||||||
messages = append(messages, openai.SystemMessage(note))
|
messages = append(messages, openai.SystemMessage(note))
|
||||||
}
|
}
|
||||||
@@ -266,7 +307,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
// the operator approved — go execute the plan now.
|
// the operator approved — go execute the plan now.
|
||||||
note := "[System: The operator approved your proposed plan. Execute it now — call request_execution or run to carry out the steps you described. Do not re-describe the plan or ask for confirmation again. The assent window is active: config_mutation commands will auto-run once you create them.]"
|
note := "[System: The operator approved your proposed plan. Execute it now — call request_execution or run to carry out the steps you described. Do not re-describe the plan or ask for confirmation again. The assent window is active: config_mutation commands will auto-run once you create them.]"
|
||||||
messages = append(messages, openai.SystemMessage(note))
|
messages = append(messages, openai.SystemMessage(note))
|
||||||
a.openAssentWindow(ctx)
|
a.openAssentWindow(ctx, sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Worker continuation: append the finished-execution note so the model
|
// Worker continuation: append the finished-execution note so the model
|
||||||
@@ -333,6 +374,9 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
|
|
||||||
if len(msg.ToolCalls) == 0 {
|
if len(msg.ToolCalls) == 0 {
|
||||||
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
|
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
|
||||||
|
if !sawSetGoal && !sawCompleteTask {
|
||||||
|
a.autoCompleteTrivialTask(ctx, sessionID, msg.Content)
|
||||||
|
}
|
||||||
emit(agentEvent{Type: "done", Data: map[string]any{
|
emit(agentEvent{Type: "done", Data: map[string]any{
|
||||||
"session_id": sessionID,
|
"session_id": sessionID,
|
||||||
"usage": acc.Usage,
|
"usage": acc.Usage,
|
||||||
@@ -352,6 +396,13 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
args = map[string]any{}
|
args = map[string]any{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch tc.Function.Name {
|
||||||
|
case "set_goal":
|
||||||
|
sawSetGoal = true
|
||||||
|
case "complete_task":
|
||||||
|
sawCompleteTask = true
|
||||||
|
}
|
||||||
|
|
||||||
emit(agentEvent{
|
emit(agentEvent{
|
||||||
Type: "tool_use",
|
Type: "tool_use",
|
||||||
Data: map[string]any{"name": tc.Function.Name, "args": args, "id": tc.ID},
|
Data: map[string]any{"name": tc.Function.Name, "args": args, "id": tc.ID},
|
||||||
@@ -360,14 +411,38 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
})
|
})
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
result, callErr := a.client.callTool(tc.Function.Name, args)
|
// Session-scoped task tools are handled in-process; everything else
|
||||||
|
// is forwarded to the shared MCP server.
|
||||||
|
var result any
|
||||||
|
var callErr error
|
||||||
|
if localRes, handled := a.handleTaskTool(ctx, sessionID, tc.Function.Name, args); handled {
|
||||||
|
result = localRes
|
||||||
|
} else {
|
||||||
|
// _session_id rides along on the wire call only — never in
|
||||||
|
// `args` (which is what gets emitted/logged/persisted as the
|
||||||
|
// model's own tool call) — so the MCP-side assent/destructive
|
||||||
|
// window checks can scope to THIS task instead of bleeding
|
||||||
|
// across every concurrently-running one sharing this agent
|
||||||
|
// identity. Not part of any tool's declared InputSchema, so
|
||||||
|
// the model never sees or supplies it.
|
||||||
|
wireArgs := make(map[string]any, len(args)+1)
|
||||||
|
for k, v := range args {
|
||||||
|
wireArgs[k] = v
|
||||||
|
}
|
||||||
|
wireArgs["_session_id"] = sessionID
|
||||||
|
var client *mcpClient
|
||||||
|
client, callErr = a.clients.get(sessionID)
|
||||||
|
if callErr == nil {
|
||||||
|
result, callErr = client.callTool(tc.Function.Name, wireArgs)
|
||||||
|
}
|
||||||
|
}
|
||||||
elapsed := int(time.Since(start).Milliseconds())
|
elapsed := int(time.Since(start).Milliseconds())
|
||||||
|
|
||||||
inputJSON, _ := json.Marshal(args)
|
inputJSON, _ := json.Marshal(args)
|
||||||
inputStr := string(inputJSON)
|
inputStr := string(inputJSON)
|
||||||
|
|
||||||
if callErr != nil {
|
if callErr != nil {
|
||||||
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, inputStr, callErr.Error(), elapsed, false, correlationID)
|
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, callErr.Error(), elapsed, false, correlationID)
|
||||||
|
|
||||||
emit(agentEvent{
|
emit(agentEvent{
|
||||||
Type: "tool_result",
|
Type: "tool_result",
|
||||||
@@ -381,7 +456,7 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
}
|
}
|
||||||
|
|
||||||
resultJSON, _ := json.Marshal(result)
|
resultJSON, _ := json.Marshal(result)
|
||||||
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, inputStr, string(resultJSON), elapsed, true, correlationID)
|
a.store.logActivity(ctx, a.agentID, sessionID, tc.Function.Name, args, inputStr, string(resultJSON), elapsed, true, correlationID)
|
||||||
|
|
||||||
// Link any execution this tool queued/started back to this
|
// Link any execution this tool queued/started back to this
|
||||||
// session, so the auto-continuation worker can feed its result
|
// session, so the auto-continuation worker can feed its result
|
||||||
@@ -392,6 +467,17 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
a.store.linkExecution(ctx, execID, sessionID)
|
a.store.linkExecution(ctx, execID, sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Record which entities this task touched (task —involves→ entity)
|
||||||
|
// and pulse them on the live context panel. Args only — never
|
||||||
|
// results — so a bulk query doesn't drag the whole fleet in.
|
||||||
|
a.store.recordTouched(ctx, sessionID, tc.Function.Name, args)
|
||||||
|
|
||||||
|
// When the agent records knowledge, link that note to this task so
|
||||||
|
// the task's outcome view shows what it learned (and pulse it live).
|
||||||
|
if tc.Function.Name == "upsert_knowledge" {
|
||||||
|
a.store.linkKnowledgeToTask(ctx, sessionID, string(resultJSON))
|
||||||
|
}
|
||||||
|
|
||||||
emit(agentEvent{
|
emit(agentEvent{
|
||||||
Type: "tool_result",
|
Type: "tool_result",
|
||||||
Data: map[string]any{"name": tc.Function.Name, "result": result, "id": tc.ID},
|
Data: map[string]any{"name": tc.Function.Name, "result": result, "id": tc.ID},
|
||||||
@@ -400,6 +486,22 @@ func (a *agent) chatWith(ctx context.Context, sessionID, message, systemInject s
|
|||||||
})
|
})
|
||||||
messages = append(messages, openai.ToolMessage(string(resultJSON), tc.ID))
|
messages = append(messages, openai.ToolMessage(string(resultJSON), tc.ID))
|
||||||
slog.Info("nomos: tool success", "tool", tc.Function.Name, "ms", elapsed)
|
slog.Info("nomos: tool success", "tool", tc.Function.Name, "ms", elapsed)
|
||||||
|
|
||||||
|
// ask_operator pauses the task: the agent has posed a decision only
|
||||||
|
// the operator can make. End the turn here so it doesn't barrel past
|
||||||
|
// its own question — the answer (panel or chat reply) resumes it.
|
||||||
|
// The prompt becomes the assistant's visible message so the question
|
||||||
|
// also shows inline in the transcript.
|
||||||
|
if tc.Function.Name == "ask_operator" {
|
||||||
|
prompt, _ := args["prompt"].(string)
|
||||||
|
emit(agentEvent{Type: "text", Data: prompt, SessionID: sessionID})
|
||||||
|
emit(agentEvent{Type: "done", Data: map[string]any{
|
||||||
|
"session_id": sessionID,
|
||||||
|
"correlation_id": correlationID,
|
||||||
|
"iteration": i + 1,
|
||||||
|
}, SessionID: sessionID})
|
||||||
|
return
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -545,8 +647,12 @@ func assistantToolCallMessage(calls []persistedCall) openai.ChatCompletionMessag
|
|||||||
// of spending its first iteration rediscovering topology it already has
|
// of spending its first iteration rediscovering topology it already has
|
||||||
// tools to query. Best-effort: an empty string on any failure just means no
|
// tools to query. Best-effort: an empty string on any failure just means no
|
||||||
// snapshot, not an error for the turn.
|
// snapshot, not an error for the turn.
|
||||||
func (a *agent) fleetSnapshot() string {
|
func (a *agent) fleetSnapshot(sessionID string) string {
|
||||||
result, err := a.client.callTool("get_health_summary", map[string]any{})
|
client, err := a.clients.get(sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
result, err := client.callTool("get_health_summary", map[string]any{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
@@ -609,11 +715,18 @@ func isRefusalOrEmpty(text string) bool {
|
|||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|
||||||
func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) {
|
func (a *agent) buildTools(sessionID string) ([]openai.ChatCompletionToolParam, error) {
|
||||||
defs, err := a.client.listToolsFull()
|
client, err := a.clients.get(sessionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
defs, err := client.listToolsFull()
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Append nomos-local, session-scoped task tools (complete_task, …) to the
|
||||||
|
// MCP tool list. They're routed to handleTaskTool, not the MCP client.
|
||||||
|
defs = append(defs, taskToolDefs()...)
|
||||||
|
|
||||||
var tools []openai.ChatCompletionToolParam
|
var tools []openai.ChatCompletionToolParam
|
||||||
for _, d := range defs {
|
for _, d := range defs {
|
||||||
@@ -634,7 +747,23 @@ func (a *agent) buildTools() ([]openai.ChatCompletionToolParam, error) {
|
|||||||
return tools, nil
|
return tools, nil
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// listToolsFull returns the MCP server's tool list, cached on this client
|
||||||
|
// after the first call (see mcpClient.toolsCache). Fix F1 of
|
||||||
|
// plans/2026-07-11-nomos-agent-code-review.md: buildTools calls this at the
|
||||||
|
// start of every chat turn, including every auto-continuation resume — the
|
||||||
|
// tool list is static for the lifetime of one MCP connection, so re-fetching
|
||||||
|
// it every single time was avoidable network+parsing work on the hot path.
|
||||||
|
// Cache invalidates on reconnectLocked (an api restart may change what's
|
||||||
|
// registered).
|
||||||
func (c *mcpClient) listToolsFull() ([]toolDef, error) {
|
func (c *mcpClient) listToolsFull() ([]toolDef, error) {
|
||||||
|
c.toolsMu.Lock()
|
||||||
|
if c.toolsCache != nil {
|
||||||
|
cached := c.toolsCache
|
||||||
|
c.toolsMu.Unlock()
|
||||||
|
return cached, nil
|
||||||
|
}
|
||||||
|
c.toolsMu.Unlock()
|
||||||
|
|
||||||
resp, err := c.doRequest("tools/list", map[string]any{})
|
resp, err := c.doRequest("tools/list", map[string]any{})
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -657,5 +786,9 @@ func (c *mcpClient) listToolsFull() ([]toolDef, error) {
|
|||||||
InputSchema: t.InputSchema,
|
InputSchema: t.InputSchema,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
c.toolsMu.Lock()
|
||||||
|
c.toolsCache = out
|
||||||
|
c.toolsMu.Unlock()
|
||||||
return out, nil
|
return out, nil
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -53,10 +53,18 @@ func extractPendingApprovals(calls []persistedCall) []pendingApproval {
|
|||||||
// don't restart it yet" contains neither "yes" nor "go ahead", but "wait"
|
// don't restart it yet" contains neither "yes" nor "go ahead", but "wait"
|
||||||
// alone should also block a stray "yes" a sentence later — checking negation
|
// alone should also block a stray "yes" a sentence later — checking negation
|
||||||
// first and returning false errs toward re-confirming rather than assuming
|
// first and returning false errs toward re-confirming rather than assuming
|
||||||
// consent, per "when in doubt, escalate").
|
// consent, per "when in doubt, escalate"). Includes contracted negatives
|
||||||
|
// ("haven't", "isn't", ...) alongside "don't"/"do not" — found live: "I
|
||||||
|
// haven't confirmed anything yet" was reading as an explicit confirmation
|
||||||
|
// because none of the contracted forms were covered, only "don't"/"do not".
|
||||||
|
// Deliberately does NOT include a bare "not": that's broad enough to false-
|
||||||
|
// negative ordinary assent ("go ahead, this is not risky") — the specific
|
||||||
|
// contracted-verb forms below are unambiguous negation on their own.
|
||||||
var negationWords = []string{
|
var negationWords = []string{
|
||||||
"no", "nope", "don't", "do not", "stop", "wait", "hold on", "hold off",
|
"no", "nope", "don't", "do not", "stop", "wait", "hold on", "hold off",
|
||||||
"not yet", "cancel", "nevermind", "never mind", "actually don't", "skip that",
|
"not yet", "cancel", "nevermind", "never mind", "actually don't", "skip that",
|
||||||
|
"haven't", "hasn't", "isn't", "wasn't", "aren't", "can't", "cannot",
|
||||||
|
"won't", "wouldn't", "shouldn't", "didn't", "doesn't",
|
||||||
}
|
}
|
||||||
|
|
||||||
// assentWords, checked only if no negation matched.
|
// assentWords, checked only if no negation matched.
|
||||||
@@ -66,19 +74,56 @@ var assentWords = []string{
|
|||||||
"lgtm", "run it", "execute", "ok go", "okay go", "please do",
|
"lgtm", "run it", "execute", "ok go", "okay go", "please do",
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// wordTokenRe splits a message into lowercase word tokens. Apostrophes
|
||||||
|
// (straight ' and curly ’) stay attached to their word so "don't"/"haven't"
|
||||||
|
// tokenize as one token, not two.
|
||||||
|
var wordTokenRe = regexp.MustCompile(`[a-z0-9'’]+`)
|
||||||
|
|
||||||
|
func tokenize(msg string) []string {
|
||||||
|
return wordTokenRe.FindAllString(strings.ToLower(strings.ReplaceAll(msg, "’", "'")), -1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// containsPhrase reports whether phrase (one or more words) appears as a
|
||||||
|
// consecutive run of WHOLE tokens in tokens — never a mid-word substring
|
||||||
|
// match. This is the fix for a real false positive found live: the old
|
||||||
|
// substring check (`strings.Contains(m, "yes")`) matched "yes" inside
|
||||||
|
// "yesterday", and "confirm" inside "confirmed"/"unconfirmed" without regard
|
||||||
|
// for word boundaries. Negation already used a word-boundary check
|
||||||
|
// (space-padded); assent/confirm words didn't — this brings both onto the
|
||||||
|
// same, more robust tokenized comparison instead of ad-hoc string padding.
|
||||||
|
func containsPhrase(tokens []string, phrase string) bool {
|
||||||
|
words := strings.Fields(phrase)
|
||||||
|
if len(words) == 0 || len(words) > len(tokens) {
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
for i := 0; i+len(words) <= len(tokens); i++ {
|
||||||
|
match := true
|
||||||
|
for j, w := range words {
|
||||||
|
if tokens[i+j] != w {
|
||||||
|
match = false
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if match {
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
// isAssent reports whether msg is a plain-language authorization of a
|
// isAssent reports whether msg is a plain-language authorization of a
|
||||||
// pending proposal. Deliberately simple and auditable: a fixed word list,
|
// pending proposal. Deliberately simple and auditable: a fixed word list,
|
||||||
// not a model judgment call, so behavior is predictable and can't be
|
// not a model judgment call, so behavior is predictable and can't be
|
||||||
// prompt-injected via the pending action's own content.
|
// prompt-injected via the pending action's own content.
|
||||||
func isAssent(msg string) bool {
|
func isAssent(msg string) bool {
|
||||||
m := " " + strings.ToLower(strings.TrimSpace(msg)) + " "
|
tokens := tokenize(msg)
|
||||||
for _, w := range negationWords {
|
for _, w := range negationWords {
|
||||||
if strings.Contains(m, " "+w+" ") || strings.HasPrefix(strings.TrimSpace(m), w+" ") || strings.HasPrefix(strings.TrimSpace(m), w+",") {
|
if containsPhrase(tokens, w) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
for _, w := range assentWords {
|
for _, w := range assentWords {
|
||||||
if strings.Contains(m, w) {
|
if containsPhrase(tokens, w) {
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -93,13 +138,13 @@ func isAssent(msg string) bool {
|
|||||||
// ("I confirm destroy 135"). Still negation-aware for the same reason as
|
// ("I confirm destroy 135"). Still negation-aware for the same reason as
|
||||||
// isAssent: "don't confirm yet" must not accidentally match.
|
// isAssent: "don't confirm yet" must not accidentally match.
|
||||||
func isTypedConfirmation(msg string) bool {
|
func isTypedConfirmation(msg string) bool {
|
||||||
m := " " + strings.ToLower(strings.TrimSpace(msg)) + " "
|
tokens := tokenize(msg)
|
||||||
for _, w := range negationWords {
|
for _, w := range negationWords {
|
||||||
if strings.Contains(m, " "+w+" ") || strings.HasPrefix(strings.TrimSpace(m), w+" ") || strings.HasPrefix(strings.TrimSpace(m), w+",") {
|
if containsPhrase(tokens, w) {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
return strings.Contains(m, "confirm")
|
return containsPhrase(tokens, "confirm") || containsPhrase(tokens, "confirmed")
|
||||||
}
|
}
|
||||||
|
|
||||||
// approveExecution grants (or denies) a pending execution via the same HTTP
|
// approveExecution grants (or denies) a pending execution via the same HTTP
|
||||||
|
|||||||
@@ -45,6 +45,43 @@ func TestIsAssent_NegationBeatsAssentWord(t *testing.T) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// TestIsAssent_WholeWordBoundary regression-tests a real false positive found
|
||||||
|
// live: the old substring check matched "yes" inside "yesterday" (and would
|
||||||
|
// equally match "confirm" inside "confirmed"/"unconfirmed" for
|
||||||
|
// isTypedConfirmation below) because only negation used a word-boundary
|
||||||
|
// check — assent/confirm words used a bare strings.Contains. Confirmed via a
|
||||||
|
// throwaway probe before being fixed; kept here permanently so a future
|
||||||
|
// change can't silently reintroduce it.
|
||||||
|
func TestIsAssent_WholeWordBoundary(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"not sure, maybe yesterday's logs show something useful",
|
||||||
|
"my eyesight isn't great, what does that say",
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if isAssent(c) {
|
||||||
|
t.Errorf("isAssent(%q) = true, want false (word-boundary: 'yes' must not match inside 'yesterday'/'eyesight')", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestIsTypedConfirmation_ContractedNegation regression-tests the other real
|
||||||
|
// false positive: isTypedConfirmation gates DESTRUCTIVE actions, and
|
||||||
|
// "confirm" matching inside "confirmed" combined with contracted negatives
|
||||||
|
// ("haven't") not being in negationWords meant a message that explicitly
|
||||||
|
// says the operator has NOT confirmed something could read as confirming it.
|
||||||
|
func TestIsTypedConfirmation_ContractedNegation(t *testing.T) {
|
||||||
|
cases := []string{
|
||||||
|
"I haven't confirmed anything yet, let me think",
|
||||||
|
"that isn't confirmed on my end",
|
||||||
|
"we can't confirm that until tomorrow",
|
||||||
|
}
|
||||||
|
for _, c := range cases {
|
||||||
|
if isTypedConfirmation(c) {
|
||||||
|
t.Errorf("isTypedConfirmation(%q) = true, want false (contracted negation should block)", c)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
func TestIsTypedConfirmation(t *testing.T) {
|
func TestIsTypedConfirmation(t *testing.T) {
|
||||||
positive := []string{
|
positive := []string{
|
||||||
"I confirm destroy 135 in strong",
|
"I confirm destroy 135 in strong",
|
||||||
|
|||||||
@@ -9,6 +9,7 @@ import (
|
|||||||
"strings"
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/safego"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -31,6 +32,71 @@ func extractExecutionIDs(toolResult string) []uuid.UUID {
|
|||||||
return out
|
return out
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// idleTaskThreshold is how long a goal-bearing session can sit non-terminal
|
||||||
|
// with no activity before the idle sweep nudges it, per
|
||||||
|
// plans/2026-07-11-task-completion-safety-net.md. Arbitrary starting point,
|
||||||
|
// not measured against real task durations — long enough that it won't fire
|
||||||
|
// mid-turn, short enough the board doesn't lie for hours.
|
||||||
|
const idleTaskThreshold = 15 * time.Minute
|
||||||
|
|
||||||
|
// runIdleSweepWorker is the safety net for case 2 of
|
||||||
|
// plans/2026-07-11-task-completion-safety-net.md: sessions that called
|
||||||
|
// set_goal (so the inline safety net in agent.go correctly left them alone,
|
||||||
|
// since they framed themselves as a real task) but then stalled without
|
||||||
|
// ever calling complete_task. Coarser than runContinuationWorker's 4s tick
|
||||||
|
// since "gone idle" is a much slower signal than "an execution just
|
||||||
|
// finished." Blocks until ctx is cancelled.
|
||||||
|
func (a *agent) runIdleSweepWorker(ctx context.Context) {
|
||||||
|
if a.store == nil {
|
||||||
|
slog.Warn("nomos: idle sweep worker disabled (no store)")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slog.Info("nomos: idle sweep worker started")
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
a.processIdleSweep(ctx)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// processIdleSweep nudges a stalled goal-bearing session once; if it's still
|
||||||
|
// non-terminal on the NEXT sweep (meaning the nudge itself went unanswered,
|
||||||
|
// not just that the model is still working), auto-closes it with a
|
||||||
|
// visible "auto-closed" outcome instead of leaving it stuck forever — same
|
||||||
|
// reasoning resumeSession already applies below for a different failure
|
||||||
|
// mode (a resume that produces no response at all).
|
||||||
|
func (a *agent) processIdleSweep(ctx context.Context) {
|
||||||
|
stale := a.store.staleGoalSessions(ctx, idleTaskThreshold, 5)
|
||||||
|
for _, s := range stale {
|
||||||
|
s := s
|
||||||
|
if s.CompletionNudges == 0 {
|
||||||
|
safego.Go("nomos:idle-nudge:"+s.ID, func() {
|
||||||
|
if err := a.store.bumpCompletionNudge(ctx, s.ID); err != nil {
|
||||||
|
slog.Error("nomos: idle nudge bump failed", "session", s.ID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
note := fmt.Sprintf("[System: this task ('%s') has been idle for %s with no complete_task call. "+
|
||||||
|
"If the goal is done (or can't be completed), call complete_task now with the outcome and a "+
|
||||||
|
"one-line summary. If you're still genuinely working through the plan, ignore this and continue.]",
|
||||||
|
s.Goal, idleTaskThreshold)
|
||||||
|
a.resumeSession(ctx, s.ID, note)
|
||||||
|
})
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
safego.Go("nomos:idle-autoclose:"+s.ID, func() {
|
||||||
|
summary := fmt.Sprintf("Auto-closed after %s idle with no response to a completion nudge.", idleTaskThreshold)
|
||||||
|
if err := a.store.completeTask(ctx, s.ID, "partial", summary); err != nil {
|
||||||
|
slog.Error("nomos: idle auto-close failed", "session", s.ID, "error", err)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// runContinuationWorker is the event loop that replaces the human typing
|
// runContinuationWorker is the event loop that replaces the human typing
|
||||||
// "continue". It polls for gated executions that (a) were initiated by a chat
|
// "continue". It polls for gated executions that (a) were initiated by a chat
|
||||||
// session and (b) have just finished, and — while that agent has an open assent
|
// session and (b) have just finished, and — while that agent has an open assent
|
||||||
@@ -55,20 +121,36 @@ func (a *agent) runContinuationWorker(ctx context.Context) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// processContinuations dispatches each pending item as its OWN goroutine
|
||||||
|
// (safego.Go, so a panic deep in one task's resumed turn — JSON parsing of
|
||||||
|
// model output, an unexpected nil in a tool result — is recovered and logged
|
||||||
|
// instead of taking down this whole function, which used to run every
|
||||||
|
// item sequentially in the SAME goroutine as the ticker loop. Two problems
|
||||||
|
// that fixed: (1) throughput — task B's continuation no longer waits for
|
||||||
|
// task A's full (up to 10-minute) resumed turn to finish first, the exact
|
||||||
|
// per-task blocking this session's earlier concurrency work removed from the
|
||||||
|
// live-chat path but had left in place here; (2) survivability — since Go
|
||||||
|
// panics unwind the goroutine they occur in, an unrecovered one here used to
|
||||||
|
// mean this call (and every future tick, since the whole ticker loop runs in
|
||||||
|
// one goroutine) would simply stop — auto-continuation for every task would
|
||||||
|
// silently die until nomos restarted. Now a single bad item can only ever
|
||||||
|
// take down its own goroutine.
|
||||||
func (a *agent) processContinuations(ctx context.Context) {
|
func (a *agent) processContinuations(ctx context.Context) {
|
||||||
pending := a.store.pendingContinuations(ctx, 5)
|
pending := a.store.pendingContinuations(ctx, 5)
|
||||||
windowOpen := a.store.assentWindowActive(ctx, a.agentID)
|
|
||||||
for _, p := range pending {
|
for _, p := range pending {
|
||||||
// Scope gate: only auto-continue while an approved plan is active.
|
// Scope gate: only auto-continue while an approved plan is active FOR
|
||||||
// A finished one-off execution with no window is left as-is (marked
|
// THIS SESSION. Checked per-item, not once for the whole batch — with
|
||||||
// continued so we don't re-check it forever) — the operator decides
|
// multiple tasks in flight, one task's open window must never cover a
|
||||||
// what happens next, as today.
|
// pending continuation belonging to a different task.
|
||||||
if !windowOpen {
|
if !a.store.assentWindowActive(ctx, a.agentID, p.SessionID) {
|
||||||
|
// A finished one-off execution with no window is left as-is
|
||||||
|
// (marked continued so we don't re-check it forever) — the
|
||||||
|
// operator decides what happens next, as today.
|
||||||
a.store.markContinued(ctx, p.ExecID)
|
a.store.markContinued(ctx, p.ExecID)
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
a.store.markContinued(ctx, p.ExecID) // stamp first: a failure here must not cause a re-continue loop
|
a.store.markContinued(ctx, p.ExecID) // stamp first: a failure here must not cause a re-continue loop
|
||||||
a.continueSession(ctx, p)
|
safego.Go("nomos:continue-session:"+p.SessionID, func() { a.continueSession(ctx, p) })
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -82,17 +164,24 @@ func (a *agent) processContinuations(ctx context.Context) {
|
|||||||
// complaint this exists to fix — polling alone only helps if there's
|
// complaint this exists to fix — polling alone only helps if there's
|
||||||
// something new to poll for.
|
// something new to poll for.
|
||||||
func (a *agent) continueSession(ctx context.Context, p pendingContinuation) {
|
func (a *agent) continueSession(ctx context.Context, p pendingContinuation) {
|
||||||
note := buildContinuationNote(p)
|
|
||||||
slog.Info("nomos: auto-continuing session", "session", p.SessionID, "execution", p.ExecID, "status", p.Status)
|
slog.Info("nomos: auto-continuing session", "session", p.SessionID, "execution", p.ExecID, "status", p.Status)
|
||||||
|
a.resumeSession(ctx, p.SessionID, buildContinuationNote(p))
|
||||||
|
}
|
||||||
|
|
||||||
|
// resumeSession re-invokes the agent for a session with a system-injected note —
|
||||||
|
// a finished execution (continueSession) or an operator's answer to a question
|
||||||
|
// (handleAnswerQuestion) — persisting progress LIVE (a placeholder row updated
|
||||||
|
// in place as each tool call lands) so the frontend poller sees each step,
|
||||||
|
// instead of total silence until the whole resume concludes.
|
||||||
|
func (a *agent) resumeSession(ctx context.Context, sessionID, note string) {
|
||||||
placeholder, _ := json.Marshal(map[string]any{
|
placeholder, _ := json.Marshal(map[string]any{
|
||||||
"role": "assistant",
|
"role": "assistant",
|
||||||
"text": "",
|
"text": "",
|
||||||
"auto": true,
|
"auto": true,
|
||||||
})
|
})
|
||||||
msgID, err := a.store.insertMessageReturningID(ctx, p.SessionID, "assistant", placeholder)
|
msgID, err := a.store.insertMessageReturningID(ctx, sessionID, "assistant", placeholder)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("nomos: continuation placeholder insert failed", "session", p.SessionID, "error", err)
|
slog.Error("nomos: resume placeholder insert failed", "session", sessionID, "error", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
var toolCalls []map[string]any
|
var toolCalls []map[string]any
|
||||||
@@ -141,17 +230,32 @@ func (a *agent) continueSession(ctx context.Context, p pendingContinuation) {
|
|||||||
errText, _ = ev.Data.(string)
|
errText, _ = ev.Data.(string)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
a.chatWith(cctx, p.SessionID, "", note, emit)
|
a.chatWith(cctx, sessionID, "", note, emit)
|
||||||
if finalText != "" || len(toolCalls) > 0 {
|
if finalText != "" || len(toolCalls) > 0 {
|
||||||
break
|
break
|
||||||
}
|
}
|
||||||
if attempt == 0 {
|
if attempt == 0 {
|
||||||
slog.Warn("nomos: auto-continuation produced nothing, retrying once", "session", p.SessionID, "execution", p.ExecID, "error", errText)
|
slog.Warn("nomos: resume produced nothing, retrying once", "session", sessionID, "error", errText)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
if errText != "" && finalText == "" {
|
if errText != "" && finalText == "" {
|
||||||
slog.Error("nomos: auto-continuation produced no response after retry", "session", p.SessionID, "execution", p.ExecID, "error", errText)
|
slog.Error("nomos: resume produced no response after retry", "session", sessionID, "error", errText)
|
||||||
|
// Give the task a real, operator-visible terminal state instead of
|
||||||
|
// leaving it silently stuck at whatever status it was in (typically
|
||||||
|
// 'executing' or 'awaiting_input') forever. Before this, a
|
||||||
|
// permanently-failed resume was invisible beyond a log line — the
|
||||||
|
// task board just showed a task that never changed, with nothing
|
||||||
|
// telling the operator it needed attention. Marking it failed here
|
||||||
|
// doesn't prevent the operator from continuing to work the task via
|
||||||
|
// a fresh chat message afterward; it just stops the silent hang.
|
||||||
|
summary := fmt.Sprintf("Auto-resume failed after retrying: %s", errText)
|
||||||
|
if len(summary) > 200 {
|
||||||
|
summary = summary[:200] + "…"
|
||||||
|
}
|
||||||
|
if cerr := a.store.completeTask(context.Background(), sessionID, "failure", summary); cerr != nil {
|
||||||
|
slog.Error("nomos: failed to mark task failed after resume gave up", "session", sessionID, "error", cerr)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
persist() // final state — same row, updated one last time with the concluding text
|
persist() // final state — same row, updated one last time with the concluding text
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -15,6 +15,9 @@ import (
|
|||||||
"sync"
|
"sync"
|
||||||
"syscall"
|
"syscall"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/safego"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
func main() {
|
func main() {
|
||||||
@@ -42,10 +45,19 @@ func main() {
|
|||||||
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
ctx, cancel := signal.NotifyContext(context.Background(), syscall.SIGTERM, syscall.SIGINT)
|
||||||
defer cancel()
|
defer cancel()
|
||||||
|
|
||||||
client, err := newMCPClient(mcpURL)
|
// One MCP client PER SESSION, not one shared client for the whole
|
||||||
if err != nil {
|
// process — see mcpClientPool's doc comment. A dedicated client is
|
||||||
|
// created lazily on each session's first tool call.
|
||||||
|
clientPool := newMCPClientPool(mcpURL)
|
||||||
|
// Prove connectivity at startup the same way the old single-client
|
||||||
|
// constructor did, so a misconfigured/unreachable MCP endpoint still
|
||||||
|
// fails fast on boot instead of only on the first real chat. Doesn't
|
||||||
|
// reuse the pool (nothing to key it by yet) — just a throwaway probe.
|
||||||
|
if probe, err := newMCPClient(mcpURL); err != nil {
|
||||||
slog.Error("nomos: mcp connect", "url", mcpURL, "error", err)
|
slog.Error("nomos: mcp connect", "url", mcpURL, "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
|
} else {
|
||||||
|
probe.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
st, err := newStore(ctx, databaseURL)
|
st, err := newStore(ctx, databaseURL)
|
||||||
@@ -57,7 +69,7 @@ func main() {
|
|||||||
defer st.close()
|
defer st.close()
|
||||||
}
|
}
|
||||||
|
|
||||||
nAgent, err := newAgent(ctx, client, st, agentSlug)
|
nAgent, err := newAgent(ctx, clientPool, st, agentSlug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("nomos: agent init", "error", err)
|
slog.Error("nomos: agent init", "error", err)
|
||||||
os.Exit(1)
|
os.Exit(1)
|
||||||
@@ -66,7 +78,25 @@ func main() {
|
|||||||
// Event-driven auto-continuation: feed finished async executions back
|
// Event-driven auto-continuation: feed finished async executions back
|
||||||
// into the agent so an approved plan runs to completion (and recovers
|
// into the agent so an approved plan runs to completion (and recovers
|
||||||
// from failures) without the operator ticking it forward each step.
|
// from failures) without the operator ticking it forward each step.
|
||||||
go nAgent.runContinuationWorker(ctx)
|
safego.Go("nomos:continuation-worker", func() { nAgent.runContinuationWorker(ctx) })
|
||||||
|
|
||||||
|
// Idle sweep for stalled goal-bearing tasks (fix 2+3 of
|
||||||
|
// plans/2026-07-11-task-completion-safety-net.md) — a coarser,
|
||||||
|
// slower-ticking counterpart to the continuation worker above.
|
||||||
|
safego.Go("nomos:idle-sweep-worker", func() { nAgent.runIdleSweepWorker(ctx) })
|
||||||
|
|
||||||
|
safego.Go("nomos:mcp-pool-sweeper", func() {
|
||||||
|
ticker := time.NewTicker(5 * time.Minute)
|
||||||
|
defer ticker.Stop()
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case <-ctx.Done():
|
||||||
|
return
|
||||||
|
case <-ticker.C:
|
||||||
|
clientPool.sweep()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
mux := http.NewServeMux()
|
mux := http.NewServeMux()
|
||||||
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
|
||||||
@@ -74,7 +104,7 @@ func main() {
|
|||||||
w.Write([]byte("ok"))
|
w.Write([]byte("ok"))
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/query", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/query", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleQuery(w, r, client, agentSlug, mcpURL)
|
handleQuery(w, r, clientPool, agentSlug, mcpURL)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/chat", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleChat(w, r, nAgent, st)
|
handleChat(w, r, nAgent, st)
|
||||||
@@ -83,7 +113,7 @@ func main() {
|
|||||||
handleSessionsList(w, r, st)
|
handleSessionsList(w, r, st)
|
||||||
})
|
})
|
||||||
mux.HandleFunc("/sessions/", func(w http.ResponseWriter, r *http.Request) {
|
mux.HandleFunc("/sessions/", func(w http.ResponseWriter, r *http.Request) {
|
||||||
handleSessionDetail(w, r, st)
|
handleSessionDetail(w, r, st, nAgent)
|
||||||
})
|
})
|
||||||
|
|
||||||
addr := os.Getenv("NOMOS_LISTEN")
|
addr := os.Getenv("NOMOS_LISTEN")
|
||||||
@@ -92,17 +122,17 @@ func main() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
srv := &http.Server{Addr: addr, Handler: mux}
|
srv := &http.Server{Addr: addr, Handler: mux}
|
||||||
go func() {
|
safego.Go("nomos:http-server", func() {
|
||||||
slog.Info("nomos: gateway listening", "addr", addr, "mcp", mcpURL, "db", databaseURL != "")
|
slog.Info("nomos: gateway listening", "addr", addr, "mcp", mcpURL, "db", databaseURL != "")
|
||||||
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
if err := srv.ListenAndServe(); err != http.ErrServerClosed {
|
||||||
slog.Error("nomos: serve", "error", err)
|
slog.Error("nomos: serve", "error", err)
|
||||||
}
|
}
|
||||||
}()
|
})
|
||||||
|
|
||||||
<-ctx.Done()
|
<-ctx.Done()
|
||||||
slog.Info("nomos: shutting down")
|
slog.Info("nomos: shutting down")
|
||||||
srv.Shutdown(context.Background())
|
srv.Shutdown(context.Background())
|
||||||
client.close()
|
clientPool.closeAll()
|
||||||
|
|
||||||
default:
|
default:
|
||||||
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
|
fmt.Fprintf(os.Stderr, "unknown command: %s\n", os.Args[1])
|
||||||
@@ -149,9 +179,21 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
|||||||
ctx := r.Context()
|
ctx := r.Context()
|
||||||
sessionID := req.SessionID
|
sessionID := req.SessionID
|
||||||
|
|
||||||
|
// pctx (persistence context) is deliberately context.Background(), not
|
||||||
|
// ctx/r.Context(), for every DB write in this handler — ctx cancels the
|
||||||
|
// instant the client disconnects (Stop button, tab close, network blip),
|
||||||
|
// and a write made with an already-cancelled context fails. Before this
|
||||||
|
// fix, the assistant message was only ever saved ONCE, at the very end,
|
||||||
|
// using ctx — so a disconnect mid-turn silently lost the ENTIRE turn's
|
||||||
|
// tool-call history from the persisted transcript, even though real work
|
||||||
|
// (executions launched, knowledge written) had already happened
|
||||||
|
// server-side. The agent's own work (a.chat below) still correctly stops
|
||||||
|
// when ctx cancels — this only changes what happens to persistence.
|
||||||
|
pctx := context.Background()
|
||||||
|
|
||||||
if sessionID == "" {
|
if sessionID == "" {
|
||||||
title := truncate(req.Message, 80)
|
title := truncate(req.Message, 80)
|
||||||
sess, err := st.createSession(ctx, title)
|
sess, err := st.createSession(pctx, title)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
slog.Error("nomos: create session", "error", err)
|
slog.Error("nomos: create session", "error", err)
|
||||||
sessionID = "ephemeral"
|
sessionID = "ephemeral"
|
||||||
@@ -159,25 +201,55 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
|||||||
sessionID = sess.ID
|
sessionID = sess.ID
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
st.touchSession(ctx, sessionID)
|
st.touchSession(pctx, sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
slog.Info("nomos: chat", "session", sessionID, "message", truncate(req.Message, 100))
|
slog.Info("nomos: chat", "session", sessionID, "message", truncate(req.Message, 100))
|
||||||
|
|
||||||
userMsg, _ := json.Marshal(map[string]any{"role": "user", "text": req.Message})
|
userMsg, _ := json.Marshal(map[string]any{"role": "user", "text": req.Message})
|
||||||
st.saveMessage(ctx, sessionID, "user", userMsg)
|
st.saveMessage(pctx, sessionID, "user", userMsg)
|
||||||
|
|
||||||
|
// If this task has a pending operator question, the incoming message IS the
|
||||||
|
// answer — close it so the panel clears. No separate resume needed: this
|
||||||
|
// chat turn is the resume, and the agent sees the question + answer in its
|
||||||
|
// replayed history.
|
||||||
|
if qid := st.openQuestionID(pctx, sessionID); qid != "" {
|
||||||
|
st.answerQuestion(pctx, sessionID, qid, req.Message)
|
||||||
|
}
|
||||||
|
|
||||||
sseEvent(w, flusher, agentEvent{Type: "session", Data: sessionID, SessionID: sessionID})
|
sseEvent(w, flusher, agentEvent{Type: "session", Data: sessionID, SessionID: sessionID})
|
||||||
|
|
||||||
toolCalls := []map[string]any{}
|
toolCalls := []map[string]any{}
|
||||||
var finalText string
|
var finalText string
|
||||||
|
|
||||||
|
// Incremental persistence, mirroring resumeSession's existing
|
||||||
|
// placeholder+update pattern (continue.go): insert a placeholder now,
|
||||||
|
// update the SAME row after every tool call, so whatever happened before
|
||||||
|
// an abort is never lost — only what hadn't happened yet is.
|
||||||
|
placeholder, _ := json.Marshal(map[string]any{"role": "assistant", "text": ""})
|
||||||
|
msgID, err := st.insertMessageReturningID(pctx, sessionID, "assistant", placeholder)
|
||||||
|
if err != nil {
|
||||||
|
slog.Error("nomos: chat placeholder insert failed", "session", sessionID, "error", err)
|
||||||
|
}
|
||||||
|
persist := func() {
|
||||||
|
if msgID == uuid.Nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
body, _ := json.Marshal(map[string]any{
|
||||||
|
"role": "assistant",
|
||||||
|
"text": finalText,
|
||||||
|
"tool_calls": toolCalls,
|
||||||
|
})
|
||||||
|
st.updateMessage(pctx, msgID, body)
|
||||||
|
}
|
||||||
|
|
||||||
a.chat(ctx, sessionID, req.Message, func(ev agentEvent) {
|
a.chat(ctx, sessionID, req.Message, func(ev agentEvent) {
|
||||||
if ev.Type == "tool_use" || ev.Type == "tool_result" {
|
if ev.Type == "tool_use" || ev.Type == "tool_result" {
|
||||||
if m, ok := ev.Data.(map[string]any); ok {
|
if m, ok := ev.Data.(map[string]any); ok {
|
||||||
m["type"] = ev.Type
|
m["type"] = ev.Type
|
||||||
toolCalls = append(toolCalls, m)
|
toolCalls = append(toolCalls, m)
|
||||||
}
|
}
|
||||||
|
persist() // live: survives even if the client disconnects right after
|
||||||
}
|
}
|
||||||
if ev.Type == "text" {
|
if ev.Type == "text" {
|
||||||
finalText, _ = ev.Data.(string)
|
finalText, _ = ev.Data.(string)
|
||||||
@@ -185,19 +257,14 @@ func handleChat(w http.ResponseWriter, r *http.Request, a *agent, st *store) {
|
|||||||
sseEvent(w, flusher, ev)
|
sseEvent(w, flusher, ev)
|
||||||
})
|
})
|
||||||
|
|
||||||
assistantMsg, _ := json.Marshal(map[string]any{
|
persist() // final state — same row, updated one last time with the concluding text
|
||||||
"role": "assistant",
|
|
||||||
"text": finalText,
|
|
||||||
"tool_calls": toolCalls,
|
|
||||||
})
|
|
||||||
st.saveMessage(ctx, sessionID, "assistant", assistantMsg)
|
|
||||||
|
|
||||||
// Generate a meaningful title from the assistant's first answer
|
// Generate a meaningful title from the assistant's first answer
|
||||||
// instead of reusing the raw user message for every session.
|
// instead of reusing the raw user message for every session.
|
||||||
if finalText != "" && sessionID != "ephemeral" {
|
if finalText != "" && sessionID != "ephemeral" {
|
||||||
title := truncate(finalText, 80)
|
title := truncate(finalText, 80)
|
||||||
if title != "" {
|
if title != "" {
|
||||||
st.updateSessionTitle(ctx, sessionID, title)
|
st.updateSessionTitle(pctx, sessionID, title)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -222,18 +289,57 @@ func handleSessionsList(w http.ResponseWriter, r *http.Request, st *store) {
|
|||||||
json.NewEncoder(w).Encode(map[string]any{"sessions": sessions})
|
json.NewEncoder(w).Encode(map[string]any{"sessions": sessions})
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store) {
|
func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store, a *agent) {
|
||||||
if st == nil {
|
if st == nil {
|
||||||
http.Error(w, "not found", 404)
|
http.Error(w, "not found", 404)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
id := strings.TrimPrefix(r.URL.Path, "/sessions/")
|
rest := strings.TrimPrefix(r.URL.Path, "/sessions/")
|
||||||
|
parts := strings.Split(rest, "/")
|
||||||
|
id := parts[0]
|
||||||
if id == "" {
|
if id == "" {
|
||||||
http.Error(w, "session id required", 400)
|
http.Error(w, "session id required", 400)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// POST /sessions/{id}/questions/{qid}/answer — the operator answers a
|
||||||
|
// pinned question from the context panel; resume the agent with the answer.
|
||||||
|
if len(parts) == 4 && parts[1] == "questions" && parts[3] == "answer" {
|
||||||
|
if r.Method != http.MethodPost {
|
||||||
|
http.Error(w, "method not allowed", 405)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
handleAnswerQuestion(w, r, st, a, id, parts[2])
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// GET /sessions/{id}/plan and /sessions/{id}/questions — REST hydration for
|
||||||
|
// the context panel when it first opens a task; live events carry deltas
|
||||||
|
// from there.
|
||||||
|
if len(parts) == 2 && r.Method == http.MethodGet {
|
||||||
|
switch parts[1] {
|
||||||
|
case "plan":
|
||||||
|
steps, err := st.getPlanSteps(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"steps": steps})
|
||||||
|
return
|
||||||
|
case "questions":
|
||||||
|
questions, err := st.getQuestions(r.Context(), id)
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
w.Header().Set("Content-Type", "application/json")
|
||||||
|
json.NewEncoder(w).Encode(map[string]any{"questions": questions})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
switch r.Method {
|
switch r.Method {
|
||||||
case http.MethodDelete:
|
case http.MethodDelete:
|
||||||
if err := st.deleteSession(r.Context(), id); err != nil {
|
if err := st.deleteSession(r.Context(), id); err != nil {
|
||||||
@@ -256,7 +362,31 @@ func handleSessionDetail(w http.ResponseWriter, r *http.Request, st *store) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agentSlug, mcpURL string) {
|
// handleAnswerQuestion records the operator's answer to a pinned question and
|
||||||
|
// resumes the agent in the background with that answer injected. Returns 202 —
|
||||||
|
// the agent's response lands via the normal message-polling path, not this POST.
|
||||||
|
func handleAnswerQuestion(w http.ResponseWriter, r *http.Request, st *store, a *agent, sessionID, questionID string) {
|
||||||
|
var req struct {
|
||||||
|
Answer string `json:"answer"`
|
||||||
|
}
|
||||||
|
if err := json.NewDecoder(r.Body).Decode(&req); err != nil || strings.TrimSpace(req.Answer) == "" {
|
||||||
|
http.Error(w, "answer is required", 400)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
prompt, _, _ := st.getQuestion(r.Context(), questionID)
|
||||||
|
if err := st.answerQuestion(r.Context(), sessionID, questionID, req.Answer); err != nil {
|
||||||
|
http.Error(w, err.Error(), 500)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if a != nil {
|
||||||
|
note := fmt.Sprintf("[System: the operator answered your question %q with: %q. "+
|
||||||
|
"Continue the task from here — do not re-ask.]", prompt, req.Answer)
|
||||||
|
safego.Go("nomos:resume-session", func() { a.resumeSession(context.Background(), sessionID, note) })
|
||||||
|
}
|
||||||
|
w.WriteHeader(202)
|
||||||
|
}
|
||||||
|
|
||||||
|
func handleQuery(w http.ResponseWriter, r *http.Request, pool *mcpClientPool, agentSlug, mcpURL string) {
|
||||||
if r.Method != http.MethodPost {
|
if r.Method != http.MethodPost {
|
||||||
http.Error(w, "method not allowed", 405)
|
http.Error(w, "method not allowed", 405)
|
||||||
return
|
return
|
||||||
@@ -272,6 +402,16 @@ func handleQuery(w http.ResponseWriter, r *http.Request, client *mcpClient, agen
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// The structured /query endpoint is stateless/session-less — "query" is a
|
||||||
|
// fixed pool key (not a real session id) so repeated calls reuse one
|
||||||
|
// dedicated connection instead of paying a fresh MCP handshake every time,
|
||||||
|
// while still never sharing a connection with an actual chat task.
|
||||||
|
client, err := pool.get("query")
|
||||||
|
if err != nil {
|
||||||
|
http.Error(w, "mcp unavailable: "+err.Error(), 502)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
start := time.Now()
|
start := time.Now()
|
||||||
|
|
||||||
if req.Tool != "" {
|
if req.Tool != "" {
|
||||||
@@ -345,7 +485,19 @@ type mcpClient struct {
|
|||||||
sessionID string
|
sessionID string
|
||||||
http *http.Client
|
http *http.Client
|
||||||
nextID int
|
nextID int
|
||||||
mu sync.Mutex // MCP is one stateful session; serialize concurrent calls
|
mu sync.Mutex // this client is one stateful MCP session; serialize ITS OWN calls
|
||||||
|
|
||||||
|
// toolsCache holds the last tools/list result. The tool list is static
|
||||||
|
// for the lifetime of one MCP connection — it only changes when the api
|
||||||
|
// process (re)registers tools, i.e. on a restart, which this client
|
||||||
|
// already detects and reacts to via reconnectLocked. Without this,
|
||||||
|
// buildTools (called at the start of EVERY chat turn, including every
|
||||||
|
// auto-continuation resume) paid a full tools/list round-trip every
|
||||||
|
// single time for a list that's almost always identical to the last one.
|
||||||
|
// Guarded separately from mu (not reused) so a cache check never
|
||||||
|
// contends with an in-flight doRequest call for a different method.
|
||||||
|
toolsMu sync.Mutex
|
||||||
|
toolsCache []toolDef
|
||||||
}
|
}
|
||||||
|
|
||||||
func newMCPClient(baseURL string) (*mcpClient, error) {
|
func newMCPClient(baseURL string) (*mcpClient, error) {
|
||||||
@@ -405,6 +557,12 @@ func (c *mcpClient) doRequest(method string, params map[string]any) (*mcpJSONRPC
|
|||||||
// reconnectLocked re-initializes the MCP session. The caller must hold c.mu.
|
// reconnectLocked re-initializes the MCP session. The caller must hold c.mu.
|
||||||
func (c *mcpClient) reconnectLocked() error {
|
func (c *mcpClient) reconnectLocked() error {
|
||||||
c.sessionID = ""
|
c.sessionID = ""
|
||||||
|
// A reconnect means the api process was restarted (or forgot us) — its
|
||||||
|
// tool registration may have changed, so the cached list is no longer
|
||||||
|
// trustworthy.
|
||||||
|
c.toolsMu.Lock()
|
||||||
|
c.toolsCache = nil
|
||||||
|
c.toolsMu.Unlock()
|
||||||
resp, err := c.send("initialize", map[string]any{
|
resp, err := c.send("initialize", map[string]any{
|
||||||
"protocolVersion": "2024-11-05",
|
"protocolVersion": "2024-11-05",
|
||||||
"capabilities": map[string]any{},
|
"capabilities": map[string]any{},
|
||||||
@@ -545,3 +703,106 @@ func (c *mcpClient) listTools() ([]string, error) {
|
|||||||
|
|
||||||
func (c *mcpClient) close() {
|
func (c *mcpClient) close() {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ─── Per-session MCP client pool ────────────────────────────────────────
|
||||||
|
//
|
||||||
|
// A single shared mcpClient serializes EVERY tool call across EVERY
|
||||||
|
// concurrently-running task through one mutex (see mcpClient.mu) — `run`
|
||||||
|
// executes its SSH command synchronously inside that lock and is capped at
|
||||||
|
// up to 10 minutes, so one task mid-`run` stalled every other task's tool
|
||||||
|
// calls, even trivial reads, behind it. The MCP *server* has no per-
|
||||||
|
// connection state to protect (newServer in internal/mcp/server.go returns
|
||||||
|
// one shared *mcp.Server instance whose tool handlers close only over the DB
|
||||||
|
// pool, which is already safe for concurrent use) — the mutex existed purely
|
||||||
|
// because the *client* reused one stateful transport session, not because
|
||||||
|
// the server needed it. Giving each task's own session its own client
|
||||||
|
// removes the cross-task serialization entirely: a task's own tool calls
|
||||||
|
// stay sequential (which they already are — the agent loop calls tools one
|
||||||
|
// at a time within a turn), but no longer block anyone else's.
|
||||||
|
type mcpClientPool struct {
|
||||||
|
baseURL string
|
||||||
|
mu sync.Mutex
|
||||||
|
clients map[string]*pooledMCPClient
|
||||||
|
}
|
||||||
|
|
||||||
|
type pooledMCPClient struct {
|
||||||
|
client *mcpClient
|
||||||
|
lastUsed time.Time
|
||||||
|
}
|
||||||
|
|
||||||
|
func newMCPClientPool(baseURL string) *mcpClientPool {
|
||||||
|
return &mcpClientPool{baseURL: baseURL, clients: make(map[string]*pooledMCPClient)}
|
||||||
|
}
|
||||||
|
|
||||||
|
// get returns the client for sessionID, creating and initializing one (a
|
||||||
|
// real MCP handshake) on first use. Session ids that don't identify a real
|
||||||
|
// persisted conversation ("" / "ephemeral", the no-DB-store path; "query",
|
||||||
|
// the structured /query endpoint) still get exactly one dedicated,
|
||||||
|
// reused client each via the same map — just keyed on a fixed string instead
|
||||||
|
// of a real session id — so that traffic doesn't pay a fresh handshake per
|
||||||
|
// request while still never sharing a connection with an actual task.
|
||||||
|
func (p *mcpClientPool) get(sessionID string) (*mcpClient, error) {
|
||||||
|
key := sessionID
|
||||||
|
if key == "" {
|
||||||
|
key = "ephemeral"
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
if pc, ok := p.clients[key]; ok {
|
||||||
|
pc.lastUsed = time.Now()
|
||||||
|
p.mu.Unlock()
|
||||||
|
return pc.client, nil
|
||||||
|
}
|
||||||
|
p.mu.Unlock()
|
||||||
|
|
||||||
|
// Initialize outside the lock — it's a network round-trip, and holding
|
||||||
|
// the pool mutex for it would serialize unrelated sessions' first calls
|
||||||
|
// behind each other, undermining the whole point of this pool.
|
||||||
|
c, err := newMCPClient(p.baseURL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
p.mu.Lock()
|
||||||
|
// Another goroutine may have created one for the same key while we were
|
||||||
|
// initializing (two of this session's tool calls racing on a cold
|
||||||
|
// start); keep whichever won, close out the loser's connection (a no-op
|
||||||
|
// today, but future-proof if mcpClient.close ever does real teardown).
|
||||||
|
if existing, ok := p.clients[key]; ok {
|
||||||
|
p.mu.Unlock()
|
||||||
|
c.close()
|
||||||
|
return existing.client, nil
|
||||||
|
}
|
||||||
|
p.clients[key] = &pooledMCPClient{client: c, lastUsed: time.Now()}
|
||||||
|
p.mu.Unlock()
|
||||||
|
return c, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// mcpClientIdleTimeout is how long an idle session's MCP client is kept
|
||||||
|
// before eviction — long enough to outlive a single slow `run` (capped at 10
|
||||||
|
// minutes server-side) plus normal think-time between a task's tool calls,
|
||||||
|
// short enough not to accumulate one abandoned connection per finished task
|
||||||
|
// forever.
|
||||||
|
const mcpClientIdleTimeout = 20 * time.Minute
|
||||||
|
|
||||||
|
// sweep evicts clients idle past mcpClientIdleTimeout. Call on a ticker.
|
||||||
|
func (p *mcpClientPool) sweep() {
|
||||||
|
cutoff := time.Now().Add(-mcpClientIdleTimeout)
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
for key, pc := range p.clients {
|
||||||
|
if pc.lastUsed.Before(cutoff) {
|
||||||
|
pc.client.close()
|
||||||
|
delete(p.clients, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
func (p *mcpClientPool) closeAll() {
|
||||||
|
p.mu.Lock()
|
||||||
|
defer p.mu.Unlock()
|
||||||
|
for key, pc := range p.clients {
|
||||||
|
pc.client.close()
|
||||||
|
delete(p.clients, key)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -4,8 +4,13 @@ import (
|
|||||||
"context"
|
"context"
|
||||||
"encoding/json"
|
"encoding/json"
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"regexp"
|
||||||
|
"strings"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||||
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5/pgxpool"
|
"github.com/jackc/pgx/v5/pgxpool"
|
||||||
)
|
)
|
||||||
@@ -37,10 +42,18 @@ func (s *store) close() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// session is a chat session elevated to a task: goal-structured work with a
|
||||||
|
// lifecycle status and an outcome (see migration 018 / the task-board plan).
|
||||||
|
// Outcome/Summary/EntityID are empty until set, hence omitempty.
|
||||||
type session struct {
|
type session struct {
|
||||||
ID string `json:"id"`
|
ID string `json:"id"`
|
||||||
Title string `json:"title"`
|
Title string `json:"title"`
|
||||||
Actor string `json:"actor"`
|
Actor string `json:"actor"`
|
||||||
|
Goal string `json:"goal"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Outcome string `json:"outcome,omitempty"`
|
||||||
|
Summary string `json:"summary,omitempty"`
|
||||||
|
EntityID string `json:"entity_id,omitempty"`
|
||||||
CreatedAt time.Time `json:"created_at"`
|
CreatedAt time.Time `json:"created_at"`
|
||||||
LastActiveAt time.Time `json:"last_active_at"`
|
LastActiveAt time.Time `json:"last_active_at"`
|
||||||
}
|
}
|
||||||
@@ -55,7 +68,7 @@ type message struct {
|
|||||||
|
|
||||||
func (s *store) createSession(ctx context.Context, title string) (*session, error) {
|
func (s *store) createSession(ctx context.Context, title string) (*session, error) {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return &session{ID: "ephemeral", Title: title, Actor: "agent:nomos"}, nil
|
return &session{ID: "ephemeral", Title: title, Actor: "agent:nomos", Status: "active"}, nil
|
||||||
}
|
}
|
||||||
var id string
|
var id string
|
||||||
err := s.pool.QueryRow(ctx,
|
err := s.pool.QueryRow(ctx,
|
||||||
@@ -64,7 +77,38 @@ func (s *store) createSession(ctx context.Context, title string) (*session, erro
|
|||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
return &session{ID: id, Title: title, Actor: "agent:nomos", CreatedAt: time.Now(), LastActiveAt: time.Now()}, nil
|
// Give the task its own entity so knowledge and involved-entity edges hang
|
||||||
|
// off the existing relationships graph. Best-effort: a failure here must not
|
||||||
|
// block the chat — the session is usable without a graph anchor.
|
||||||
|
entityID := s.createTaskEntity(ctx, id, title)
|
||||||
|
return &session{ID: id, Title: title, Actor: "agent:nomos", Status: "active",
|
||||||
|
EntityID: entityID, CreatedAt: time.Now(), LastActiveAt: time.Now()}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// createTaskEntity creates (or reuses) the task:<session-id> entity that
|
||||||
|
// anchors this task's knowledge and involved-entity relationships, and records
|
||||||
|
// it on the session. Returns the entity id, or "" on failure — non-fatal, see
|
||||||
|
// caller. Requires the 'task' entity type (seeds/ontology.yaml).
|
||||||
|
func (s *store) createTaskEntity(ctx context.Context, sessionID, title string) string {
|
||||||
|
entityID, _ := uuid.NewV7()
|
||||||
|
slug := "task:" + sessionID
|
||||||
|
// name is UNIQUE(type,name) and chat titles collide ("hi" ×6), so key the
|
||||||
|
// name on the session id and keep the human title in attributes for display.
|
||||||
|
name := "task " + sessionID
|
||||||
|
attrs, _ := json.Marshal(map[string]any{"title": title})
|
||||||
|
if err := s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO entities (id, slug, type, name, attributes)
|
||||||
|
VALUES ($1, $2, 'task', $3, $4)
|
||||||
|
ON CONFLICT (slug) DO UPDATE SET updated_at = now()
|
||||||
|
RETURNING id`, entityID, slug, name, string(attrs)).Scan(&entityID); err != nil {
|
||||||
|
slog.Warn("nomos: could not create task entity", "session", sessionID, "error", err)
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
if _, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE agent_sessions SET entity_id = $1 WHERE id = $2`, entityID, sessionID); err != nil {
|
||||||
|
slog.Warn("nomos: could not link task entity", "session", sessionID, "error", err)
|
||||||
|
}
|
||||||
|
return entityID.String()
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) saveMessage(ctx context.Context, sessionID, role string, content json.RawMessage) error {
|
func (s *store) saveMessage(ctx context.Context, sessionID, role string, content json.RawMessage) error {
|
||||||
@@ -151,7 +195,9 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
|
|||||||
return nil, nil
|
return nil, nil
|
||||||
}
|
}
|
||||||
rows, err := s.pool.Query(ctx,
|
rows, err := s.pool.Query(ctx,
|
||||||
`SELECT id, title, actor, created_at, last_active_at FROM agent_sessions ORDER BY last_active_at DESC LIMIT 50`)
|
`SELECT id, title, actor, goal, status, COALESCE(outcome, ''), summary,
|
||||||
|
COALESCE(entity_id::text, ''), created_at, last_active_at
|
||||||
|
FROM agent_sessions ORDER BY last_active_at DESC LIMIT 50`)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
@@ -160,7 +206,8 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
|
|||||||
var out []session
|
var out []session
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
var sess session
|
var sess session
|
||||||
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.CreatedAt, &sess.LastActiveAt); err != nil {
|
if err := rows.Scan(&sess.ID, &sess.Title, &sess.Actor, &sess.Goal, &sess.Status,
|
||||||
|
&sess.Outcome, &sess.Summary, &sess.EntityID, &sess.CreatedAt, &sess.LastActiveAt); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
out = append(out, sess)
|
out = append(out, sess)
|
||||||
@@ -168,6 +215,12 @@ func (s *store) listSessions(ctx context.Context) ([]session, error) {
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getMessages returns a session's ENTIRE message history, unbounded — used
|
||||||
|
// for the UI's own transcript view (GET /sessions/{id}), where the operator
|
||||||
|
// should be able to see everything a task has done regardless of how long
|
||||||
|
// it's run. For LLM replay, see getRecentMessages: sending the operator's
|
||||||
|
// full transcript is fine; sending the model's full transcript on every
|
||||||
|
// single turn is not (see getRecentMessages's doc comment).
|
||||||
func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, error) {
|
func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, error) {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return nil, nil
|
return nil, nil
|
||||||
@@ -191,16 +244,517 @@ func (s *store) getMessages(ctx context.Context, sessionID string) ([]message, e
|
|||||||
return out, rows.Err()
|
return out, rows.Err()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// getRecentMessages returns the most recent `limit` messages for sessionID,
|
||||||
|
// in chronological order, plus whether older messages exist beyond that
|
||||||
|
// window. Used specifically for LLM replay (chatWith): without a bound,
|
||||||
|
// every turn re-sent the ENTIRE session history into the model's context,
|
||||||
|
// unconditionally growing with every turn — a real, observed-in-production
|
||||||
|
// cost/latency/eventual-context-limit risk for exactly the long-running,
|
||||||
|
// heavily-autonomous tasks (many auto-continuation cycles) this system is
|
||||||
|
// built to run longest. Fetches limit+1 rows to detect "there's more"
|
||||||
|
// without a separate COUNT query.
|
||||||
|
func (s *store) getRecentMessages(ctx context.Context, sessionID string, limit int) (msgs []message, truncated bool, err error) {
|
||||||
|
if s == nil {
|
||||||
|
return nil, false, nil
|
||||||
|
}
|
||||||
|
rows, qerr := s.pool.Query(ctx,
|
||||||
|
`SELECT id, session_id, role, content, created_at FROM agent_messages
|
||||||
|
WHERE session_id=$1 ORDER BY created_at DESC LIMIT $2`,
|
||||||
|
sessionID, limit+1)
|
||||||
|
if qerr != nil {
|
||||||
|
return nil, false, qerr
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
|
||||||
|
var out []message
|
||||||
|
for rows.Next() {
|
||||||
|
var m message
|
||||||
|
if err := rows.Scan(&m.ID, &m.SessionID, &m.Role, &m.Content, &m.CreatedAt); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
out = append(out, m)
|
||||||
|
}
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return nil, false, err
|
||||||
|
}
|
||||||
|
|
||||||
|
truncated = len(out) > limit
|
||||||
|
if truncated {
|
||||||
|
out = out[:limit]
|
||||||
|
}
|
||||||
|
// Rows came back newest-first (for the LIMIT to bound the right end);
|
||||||
|
// reverse to chronological order for replay.
|
||||||
|
for i, j := 0, len(out)-1; i < j; i, j = i+1, j-1 {
|
||||||
|
out[i], out[j] = out[j], out[i]
|
||||||
|
}
|
||||||
|
return out, truncated, nil
|
||||||
|
}
|
||||||
|
|
||||||
func (s *store) deleteSession(ctx context.Context, id string) error {
|
func (s *store) deleteSession(ctx context.Context, id string) error {
|
||||||
if s == nil {
|
if s == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
_, err := s.pool.Exec(ctx, `DELETE FROM agent_messages WHERE session_id = $1`, id)
|
// Resolve the task entity so we can clean up its graph edges and events too
|
||||||
|
// — otherwise deleting a session orphans its task:<id> entity, its involves/
|
||||||
|
// documents relationships, and its task-scoped events.
|
||||||
|
var entID uuid.UUID
|
||||||
|
s.pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, id).Scan(&entID)
|
||||||
|
|
||||||
|
if _, err := s.pool.Exec(ctx, `DELETE FROM agent_messages WHERE session_id = $1`, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// task.status / entity.touched / knowledge.recorded are all correlated by
|
||||||
|
// session id.
|
||||||
|
s.pool.Exec(ctx, `DELETE FROM events WHERE correlation_id = $1`, id)
|
||||||
|
if _, err := s.pool.Exec(ctx, `DELETE FROM agent_sessions WHERE id = $1`, id); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
if entID != uuid.Nil {
|
||||||
|
// relationships FK is ON DELETE RESTRICT, so drop the task's edges first.
|
||||||
|
s.pool.Exec(ctx, `DELETE FROM relationships WHERE source_id = $1 OR target_id = $1`, entID)
|
||||||
|
s.pool.Exec(ctx, `DELETE FROM entities WHERE id = $1`, entID)
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// taskEntityPtr returns the task entity id for a session, or nil — used as the
|
||||||
|
// entity_id on task-scoped events so they anchor to the task in the graph.
|
||||||
|
func (s *store) taskEntityPtr(ctx context.Context, sessionID string) *uuid.UUID {
|
||||||
|
var id uuid.UUID
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&id); err != nil || id == uuid.Nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return &id
|
||||||
|
}
|
||||||
|
|
||||||
|
// setGoal records the task's goal and moves it into planning. Emits goal.set.
|
||||||
|
func (s *store) setGoal(ctx context.Context, sessionID, goal string) error {
|
||||||
|
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
if _, err := s.pool.Exec(ctx,
|
||||||
|
`UPDATE agent_sessions SET goal = $2, status = 'planning', last_active_at = now() WHERE id = $1`,
|
||||||
|
sessionID, goal); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
_ = observability.Event(ctx, sqlcgen.New(s.pool), "goal.set", s.taskEntityPtr(ctx, sessionID),
|
||||||
|
"info", "nomos", sessionID, map[string]any{"goal": goal})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// planStepInput is one step as the agent proposes it.
|
||||||
|
type planStepInput struct {
|
||||||
|
Title string
|
||||||
|
Detail string
|
||||||
|
TargetSlug string
|
||||||
|
}
|
||||||
|
|
||||||
|
// proposePlan sets the task's plan and moves it into executing. Emits
|
||||||
|
// plan.proposed with the persisted steps (seq + id) so the panel can render
|
||||||
|
// and later address them by id.
|
||||||
|
//
|
||||||
|
// Two modes, chosen by whether any existing step has left 'pending':
|
||||||
|
// - Fresh/revise (no step started yet): full replace (delete + insert). This
|
||||||
|
// covers the first call, and a genuine re-plan before any work began.
|
||||||
|
// - Mid-flight (some step is running/done/failed/…): APPEND the new steps
|
||||||
|
// after the current max seq instead of wiping. The model is instructed to
|
||||||
|
// propose the whole plan in one call, but nothing stops it from calling
|
||||||
|
// propose_plan again per-step as it goes — a destructive replace in that
|
||||||
|
// case would erase every already-completed step, leaving the operator
|
||||||
|
// seeing only the most recent single step ("1/1") instead of real
|
||||||
|
// progress. Appending makes the panel's step history correct regardless
|
||||||
|
// of how the model chooses to call the tool.
|
||||||
|
func (s *store) proposePlan(ctx context.Context, sessionID string, steps []planStepInput) ([]map[string]any, error) {
|
||||||
|
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
tx, err := s.pool.Begin(ctx)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer tx.Rollback(ctx)
|
||||||
|
|
||||||
|
var startSeq int
|
||||||
|
var anyStarted bool
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
SELECT COALESCE(max(seq), 0), COALESCE(bool_or(status <> 'pending'), false)
|
||||||
|
FROM session_plan_steps WHERE session_id = $1`, sessionID).Scan(&startSeq, &anyStarted); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if !anyStarted {
|
||||||
|
if _, err := tx.Exec(ctx, `DELETE FROM session_plan_steps WHERE session_id = $1`, sessionID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
startSeq = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
out := make([]map[string]any, 0, len(steps))
|
||||||
|
for i, st := range steps {
|
||||||
|
var targetSlug *string
|
||||||
|
if st.TargetSlug != "" {
|
||||||
|
targetSlug = &st.TargetSlug
|
||||||
|
}
|
||||||
|
seq := startSeq + i + 1
|
||||||
|
var id uuid.UUID
|
||||||
|
if err := tx.QueryRow(ctx, `
|
||||||
|
INSERT INTO session_plan_steps (session_id, seq, title, detail, target_slug)
|
||||||
|
VALUES ($1, $2, $3, $4, $5) RETURNING id`,
|
||||||
|
sessionID, seq, st.Title, st.Detail, targetSlug).Scan(&id); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
out = append(out, map[string]any{
|
||||||
|
"id": id.String(), "seq": seq, "title": st.Title,
|
||||||
|
"detail": st.Detail, "target_slug": st.TargetSlug,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
if _, err := tx.Exec(ctx,
|
||||||
|
`UPDATE agent_sessions SET status = 'executing', last_active_at = now() WHERE id = $1`, sessionID); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
if err := tx.Commit(ctx); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
// Event after commit so subscribers only ever see a persisted plan.
|
||||||
|
// appended=true tells the panel to add these steps to its existing list
|
||||||
|
// rather than replace it (mirrors the mid-flight append above).
|
||||||
|
_ = observability.Event(ctx, sqlcgen.New(s.pool), "plan.proposed", s.taskEntityPtr(ctx, sessionID),
|
||||||
|
"info", "nomos", sessionID, map[string]any{"steps": out, "appended": anyStarted})
|
||||||
|
return out, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// updatePlanStep sets a step's status by seq, stamping started_at/finished_at
|
||||||
|
// and linking an execution if given. Emits plan.step.started (running) or
|
||||||
|
// plan.step.finished (terminal) so the panel advances live. The execution link
|
||||||
|
// is also what lets the api auto-close the step when the execution finishes
|
||||||
|
// (see closePlanStepForExecution).
|
||||||
|
func (s *store) updatePlanStep(ctx context.Context, sessionID string, seq int, status, execID string) error {
|
||||||
|
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
stamp := ""
|
||||||
|
switch status {
|
||||||
|
case "running":
|
||||||
|
stamp = ", started_at = COALESCE(started_at, now())"
|
||||||
|
case "done", "failed", "skipped", "blocked":
|
||||||
|
stamp = ", finished_at = now()"
|
||||||
|
}
|
||||||
|
var execPtr *uuid.UUID
|
||||||
|
if id, err := uuid.Parse(execID); err == nil {
|
||||||
|
execPtr = &id
|
||||||
|
}
|
||||||
|
var stepID uuid.UUID
|
||||||
|
var targetSlug *string
|
||||||
|
// stamp is a fixed literal from the switch above — never user input.
|
||||||
|
if err := s.pool.QueryRow(ctx, `
|
||||||
|
UPDATE session_plan_steps
|
||||||
|
SET status = $3, execution_id = COALESCE($4, execution_id)`+stamp+`
|
||||||
|
WHERE session_id = $1 AND seq = $2
|
||||||
|
RETURNING id, target_slug`, sessionID, seq, status, execPtr).Scan(&stepID, &targetSlug); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
// Anchor the event to the step's target entity when it has one, else the task.
|
||||||
|
entPtr := s.taskEntityPtr(ctx, sessionID)
|
||||||
|
if targetSlug != nil && *targetSlug != "" {
|
||||||
|
var tid uuid.UUID
|
||||||
|
if s.pool.QueryRow(ctx, `SELECT id FROM entities WHERE slug = $1`, *targetSlug).Scan(&tid) == nil {
|
||||||
|
entPtr = &tid
|
||||||
|
}
|
||||||
|
}
|
||||||
|
evType := "plan.step.finished"
|
||||||
|
if status == "running" {
|
||||||
|
evType = "plan.step.started"
|
||||||
|
}
|
||||||
|
data := map[string]any{"step_id": stepID.String(), "seq": seq, "status": status}
|
||||||
|
if execID != "" {
|
||||||
|
data["execution_id"] = execID
|
||||||
|
}
|
||||||
|
_ = observability.Event(ctx, sqlcgen.New(s.pool), evType, entPtr, "info", "nomos", sessionID, data)
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// completeTask sets a task's terminal state, outcome, and one-line summary,
|
||||||
|
// mirrors the outcome onto the task entity's attributes (so the board/graph
|
||||||
|
// show it), and publishes task.status for the live context panel. outcome is
|
||||||
|
// success|failure|partial; status is derived (failure → failed, else done).
|
||||||
|
func (s *store) completeTask(ctx context.Context, sessionID, outcome, summary string) error {
|
||||||
|
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
status := "done"
|
||||||
|
if outcome == "failure" {
|
||||||
|
status = "failed"
|
||||||
|
}
|
||||||
|
if _, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE agent_sessions SET status = $2, outcome = $3, summary = $4, last_active_at = now()
|
||||||
|
WHERE id = $1`, sessionID, status, outcome, summary); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
var entID uuid.UUID
|
||||||
|
s.pool.QueryRow(ctx, `SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&entID)
|
||||||
|
var entPtr *uuid.UUID
|
||||||
|
if entID != uuid.Nil {
|
||||||
|
attrs, _ := json.Marshal(map[string]any{"outcome": outcome, "status": status, "summary": summary})
|
||||||
|
s.pool.Exec(ctx, `UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now() WHERE id = $1`,
|
||||||
|
entID, string(attrs))
|
||||||
|
entPtr = &entID
|
||||||
|
}
|
||||||
|
severity := "info"
|
||||||
|
if outcome == "failure" {
|
||||||
|
severity = "warning"
|
||||||
|
}
|
||||||
|
_ = observability.Event(ctx, sqlcgen.New(s.pool), "task.status", entPtr, severity, "nomos", sessionID,
|
||||||
|
map[string]any{"status": status, "outcome": outcome, "summary": summary})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// staleGoalSession is a goal-bearing task that's gone idle without reaching
|
||||||
|
// a terminal state — the idle-sweep worker's work list (fix 2+3 of
|
||||||
|
// plans/2026-07-11-task-completion-safety-net.md).
|
||||||
|
type staleGoalSession struct {
|
||||||
|
ID string
|
||||||
|
Goal string
|
||||||
|
CompletionNudges int
|
||||||
|
}
|
||||||
|
|
||||||
|
// staleGoalSessions finds sessions that framed themselves as a real task
|
||||||
|
// (goal != '', so the inline safety net in agent.go intentionally left them
|
||||||
|
// alone) but have sat non-terminal past idleThreshold. completion_nudges
|
||||||
|
// tells the caller whether to nudge (0) or give up and auto-close (>=1) —
|
||||||
|
// see processIdleSweep in continue.go.
|
||||||
|
func (s *store) staleGoalSessions(ctx context.Context, idleThreshold time.Duration, limit int) []staleGoalSession {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id, goal, completion_nudges
|
||||||
|
FROM agent_sessions
|
||||||
|
WHERE goal <> ''
|
||||||
|
AND status IN ('active', 'planning', 'executing')
|
||||||
|
AND last_active_at < now() - ($1 * interval '1 second')
|
||||||
|
ORDER BY last_active_at
|
||||||
|
LIMIT $2`, idleThreshold.Seconds(), limit)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []staleGoalSession
|
||||||
|
for rows.Next() {
|
||||||
|
var s staleGoalSession
|
||||||
|
if err := rows.Scan(&s.ID, &s.Goal, &s.CompletionNudges); err == nil {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// bumpCompletionNudge records that the idle sweep nudged a stalled session,
|
||||||
|
// stamping last_active_at so it isn't picked up again until it's genuinely
|
||||||
|
// idle again (a fresh nudge shouldn't fire every tick while the model is
|
||||||
|
// mid-response to the previous one).
|
||||||
|
func (s *store) bumpCompletionNudge(ctx context.Context, sessionID string) error {
|
||||||
|
if s == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
_, err := s.pool.Exec(ctx, `
|
||||||
|
UPDATE agent_sessions SET completion_nudges = completion_nudges + 1, last_active_at = now()
|
||||||
|
WHERE id = $1`, sessionID)
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
|
||||||
|
// planStep is a persisted plan step, as returned to the frontend for hydration
|
||||||
|
// (the panel otherwise only sees steps live via plan.proposed/plan.step.*).
|
||||||
|
type planStep struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Seq int `json:"seq"`
|
||||||
|
Title string `json:"title"`
|
||||||
|
Detail string `json:"detail"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
ExecutionID *string `json:"execution_id,omitempty"`
|
||||||
|
TargetSlug *string `json:"target_slug,omitempty"`
|
||||||
|
StartedAt *string `json:"started_at,omitempty"`
|
||||||
|
FinishedAt *string `json:"finished_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getPlanSteps returns a task's plan in order — REST hydration for the context
|
||||||
|
// panel when it first opens a task (live events only carry deltas from then on).
|
||||||
|
func (s *store) getPlanSteps(ctx context.Context, sessionID string) ([]planStep, error) {
|
||||||
|
if s == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id::text, seq, title, detail, status,
|
||||||
|
execution_id::text, target_slug,
|
||||||
|
started_at::text, finished_at::text
|
||||||
|
FROM session_plan_steps WHERE session_id = $1 ORDER BY seq`, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []planStep
|
||||||
|
for rows.Next() {
|
||||||
|
var st planStep
|
||||||
|
var execID, target, started, finished *string
|
||||||
|
if err := rows.Scan(&st.ID, &st.Seq, &st.Title, &st.Detail, &st.Status,
|
||||||
|
&execID, &target, &started, &finished); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
st.ExecutionID, st.TargetSlug, st.StartedAt, st.FinishedAt = execID, target, started, finished
|
||||||
|
out = append(out, st)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// sessionQuestion is a persisted question, as returned to the frontend.
|
||||||
|
type sessionQuestion struct {
|
||||||
|
ID string `json:"id"`
|
||||||
|
Prompt string `json:"prompt"`
|
||||||
|
Context map[string]any `json:"context"`
|
||||||
|
Status string `json:"status"`
|
||||||
|
Answer *string `json:"answer,omitempty"`
|
||||||
|
CreatedAt string `json:"created_at"`
|
||||||
|
AnsweredAt *string `json:"answered_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// getQuestions returns a task's questions (open and answered) newest-first —
|
||||||
|
// REST hydration for the context panel's pinned question card and history.
|
||||||
|
func (s *store) getQuestions(ctx context.Context, sessionID string) ([]sessionQuestion, error) {
|
||||||
|
if s == nil {
|
||||||
|
return nil, nil
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx, `
|
||||||
|
SELECT id::text, prompt, context, status, answer, created_at::text, answered_at::text
|
||||||
|
FROM session_questions WHERE session_id = $1 ORDER BY created_at DESC`, sessionID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
defer rows.Close()
|
||||||
|
var out []sessionQuestion
|
||||||
|
for rows.Next() {
|
||||||
|
var q sessionQuestion
|
||||||
|
var ctxJSON []byte
|
||||||
|
var answer, answeredAt *string
|
||||||
|
if err := rows.Scan(&q.ID, &q.Prompt, &ctxJSON, &q.Status, &answer, &q.CreatedAt, &answeredAt); err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
json.Unmarshal(ctxJSON, &q.Context)
|
||||||
|
q.Answer, q.AnsweredAt = answer, answeredAt
|
||||||
|
out = append(out, q)
|
||||||
|
}
|
||||||
|
return out, rows.Err()
|
||||||
|
}
|
||||||
|
|
||||||
|
// askOperator records a structured decision the agent needs from the operator,
|
||||||
|
// moves the task to awaiting_input, and emits question.raised so the context
|
||||||
|
// panel pins it. qctx carries {why, options, entities}. Returns the question id.
|
||||||
|
func (s *store) askOperator(ctx context.Context, sessionID, prompt string, qctx map[string]any) (string, error) {
|
||||||
|
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||||
|
return "", nil
|
||||||
|
}
|
||||||
|
ctxJSON, _ := json.Marshal(qctx)
|
||||||
|
var qid uuid.UUID
|
||||||
|
if err := s.pool.QueryRow(ctx, `
|
||||||
|
INSERT INTO session_questions (session_id, prompt, context) VALUES ($1, $2, $3) RETURNING id`,
|
||||||
|
sessionID, prompt, string(ctxJSON)).Scan(&qid); err != nil {
|
||||||
|
return "", err
|
||||||
|
}
|
||||||
|
s.pool.Exec(ctx, `UPDATE agent_sessions SET status = 'awaiting_input', last_active_at = now() WHERE id = $1`, sessionID)
|
||||||
|
data := map[string]any{"question_id": qid.String(), "prompt": prompt}
|
||||||
|
for k, v := range qctx {
|
||||||
|
data[k] = v
|
||||||
|
}
|
||||||
|
_ = observability.Event(ctx, sqlcgen.New(s.pool), "question.raised", s.taskEntityPtr(ctx, sessionID),
|
||||||
|
"warning", "nomos", sessionID, data)
|
||||||
|
return qid.String(), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// openQuestionID returns the id of the session's open question, or "". Used to
|
||||||
|
// auto-close a pending question when the operator answers via a plain chat reply.
|
||||||
|
func (s *store) openQuestionID(ctx context.Context, sessionID string) string {
|
||||||
|
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||||
|
return ""
|
||||||
|
}
|
||||||
|
var qid string
|
||||||
|
s.pool.QueryRow(ctx, `SELECT id::text FROM session_questions
|
||||||
|
WHERE session_id = $1 AND status = 'open' ORDER BY created_at DESC LIMIT 1`, sessionID).Scan(&qid)
|
||||||
|
return qid
|
||||||
|
}
|
||||||
|
|
||||||
|
// getQuestion returns a question's prompt, answer, and session — used to build
|
||||||
|
// the resume note when the operator answers via the panel.
|
||||||
|
func (s *store) getQuestion(ctx context.Context, questionID string) (prompt, answer, sessionID string) {
|
||||||
|
if s == nil || questionID == "" {
|
||||||
|
return "", "", ""
|
||||||
|
}
|
||||||
|
qid, err := uuid.Parse(questionID)
|
||||||
|
if err != nil {
|
||||||
|
return "", "", ""
|
||||||
|
}
|
||||||
|
s.pool.QueryRow(ctx, `SELECT prompt, COALESCE(answer, ''), session_id::text
|
||||||
|
FROM session_questions WHERE id = $1`, qid).Scan(&prompt, &answer, &sessionID)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// answerQuestion records the operator's answer, returns the task to executing,
|
||||||
|
// and emits question.answered. It does NOT itself resume the agent — the caller
|
||||||
|
// decides: a chat reply IS the resuming turn, while a panel answer triggers a
|
||||||
|
// continuation.
|
||||||
|
func (s *store) answerQuestion(ctx context.Context, sessionID, questionID, answer string) error {
|
||||||
|
if s == nil || sessionID == "" || sessionID == "ephemeral" || questionID == "" {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
qid, err := uuid.Parse(questionID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
_, err = s.pool.Exec(ctx, `DELETE FROM agent_sessions WHERE id = $1`, id)
|
if _, err := s.pool.Exec(ctx, `
|
||||||
return err
|
UPDATE session_questions SET status = 'answered', answer = $2, answered_at = now()
|
||||||
|
WHERE id = $1 AND status = 'open'`, qid, answer); err != nil {
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
s.pool.Exec(ctx, `UPDATE agent_sessions SET status = 'executing', last_active_at = now() WHERE id = $1`, sessionID)
|
||||||
|
_ = observability.Event(ctx, sqlcgen.New(s.pool), "question.answered", s.taskEntityPtr(ctx, sessionID),
|
||||||
|
"info", "nomos", sessionID, map[string]any{"question_id": questionID, "answer": answer})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// knowledgeSlugRe matches a nomos knowledge doc slug (<kind>:nomos/<title>) as
|
||||||
|
// printed in upsert_knowledge's result text.
|
||||||
|
var knowledgeSlugRe = regexp.MustCompile(`[a-z]+:nomos/[a-z0-9-]+`)
|
||||||
|
|
||||||
|
// linkKnowledgeToTask runs after a successful upsert_knowledge call within a
|
||||||
|
// task: it links the created knowledge doc to the task entity (documents) so
|
||||||
|
// get_relations(task) surfaces what the task learned, and publishes
|
||||||
|
// knowledge.recorded for the live panel. Best-effort. The doc is ALSO linked to
|
||||||
|
// the entity it's "about" by upsert_knowledge itself — that about-link is the
|
||||||
|
// retrieval path future tasks use (get_entity_knowledge); this task-link is for
|
||||||
|
// the task's own outcome/knowledge view.
|
||||||
|
func (s *store) linkKnowledgeToTask(ctx context.Context, sessionID, resultText string) {
|
||||||
|
if s == nil || sessionID == "" || sessionID == "ephemeral" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slug := knowledgeSlugRe.FindString(resultText)
|
||||||
|
if slug == "" {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var taskID, docID uuid.UUID
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&taskID); err != nil || taskID == uuid.Nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT id FROM entities WHERE slug = $1`, slug).Scan(&docID); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||||
|
SELECT $1, $2, 'documents', '{"by":"nomos"}'::jsonb, now()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM relationships
|
||||||
|
WHERE source_id = $1 AND target_id = $2 AND type = 'documents' AND valid_to IS NULL)`,
|
||||||
|
docID, taskID)
|
||||||
|
_ = observability.Event(ctx, sqlcgen.New(s.pool), "knowledge.recorded", &docID, "info", "nomos", sessionID,
|
||||||
|
map[string]any{"slug": slug})
|
||||||
}
|
}
|
||||||
|
|
||||||
func (s *store) updateSessionTitle(ctx context.Context, id, title string) error {
|
func (s *store) updateSessionTitle(ctx context.Context, id, title string) error {
|
||||||
@@ -237,6 +791,106 @@ func (s *store) linkExecution(ctx context.Context, execID uuid.UUID, sessionID s
|
|||||||
VALUES ($1, $2) ON CONFLICT (execution_id) DO NOTHING`, execID, sessionID)
|
VALUES ($1, $2) ON CONFLICT (execution_id) DO NOTHING`, execID, sessionID)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// taskSlugRe matches an entity slug: a lowercase type prefix then colon-
|
||||||
|
// separated segments (host:hubris, lxc:caddy, check:ping:8cf). Mirrors the
|
||||||
|
// frontend SessionGraph regex so the panel and the involves-graph agree on
|
||||||
|
// what counts as an entity reference.
|
||||||
|
var taskSlugRe = regexp.MustCompile(`[a-z][a-z-]*:[a-z0-9][a-z0-9._/-]*(?::[a-z0-9._/-]+)*`)
|
||||||
|
|
||||||
|
// touchExcludedTypes are entity types too noisy to record as task involvement:
|
||||||
|
// a health question names dozens of check:… slugs, executions/tasks are
|
||||||
|
// bookkeeping, not things the task "worked on".
|
||||||
|
var touchExcludedTypes = map[string]bool{"check": true, "execution": true, "task": true}
|
||||||
|
|
||||||
|
// recordTouched links the task to every entity referenced in a tool call's
|
||||||
|
// args (task —involves→ entity) and publishes one entity.touched event per
|
||||||
|
// entity so the live context panel can pulse it. Best-effort: it never blocks
|
||||||
|
// or fails the tool call. Only args are inspected — what the agent chose to act
|
||||||
|
// on — never results, since a single bulk query result would otherwise pull the
|
||||||
|
// whole fleet into the task's graph.
|
||||||
|
func (s *store) recordTouched(ctx context.Context, sessionID, toolName string, args map[string]any) {
|
||||||
|
if s == nil || sessionID == "" || sessionID == "ephemeral" || len(args) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
slugs := map[string]struct{}{}
|
||||||
|
collectTaskSlugs(args, slugs)
|
||||||
|
if len(slugs) == 0 {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
var taskEntityID uuid.UUID
|
||||||
|
if err := s.pool.QueryRow(ctx,
|
||||||
|
`SELECT entity_id FROM agent_sessions WHERE id = $1`, sessionID).Scan(&taskEntityID); err != nil || taskEntityID == uuid.Nil {
|
||||||
|
return // no task entity to anchor edges on
|
||||||
|
}
|
||||||
|
|
||||||
|
// One batched lookup instead of a SELECT per slug — a tool call naming
|
||||||
|
// several entities (e.g. a multi-target comparison) used to issue N
|
||||||
|
// round-trips here for N slugs found in its args.
|
||||||
|
slugList := make([]string, 0, len(slugs))
|
||||||
|
for slug := range slugs {
|
||||||
|
slugList = append(slugList, slug)
|
||||||
|
}
|
||||||
|
rows, err := s.pool.Query(ctx,
|
||||||
|
`SELECT id, type, slug FROM entities WHERE slug = ANY($1)`, slugList)
|
||||||
|
if err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
type found struct {
|
||||||
|
id uuid.UUID
|
||||||
|
etype string
|
||||||
|
}
|
||||||
|
matched := make(map[string]found, len(slugList))
|
||||||
|
for rows.Next() {
|
||||||
|
var f found
|
||||||
|
var slug string
|
||||||
|
if rows.Scan(&f.id, &f.etype, &slug) == nil {
|
||||||
|
matched[slug] = f
|
||||||
|
}
|
||||||
|
}
|
||||||
|
rows.Close()
|
||||||
|
if err := rows.Err(); err != nil {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
q := sqlcgen.New(s.pool)
|
||||||
|
for slug, f := range matched {
|
||||||
|
if touchExcludedTypes[f.etype] || f.id == taskEntityID {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
// Idempotent involves edge (task → entity), same guard as upsert_knowledge.
|
||||||
|
s.pool.Exec(ctx, `
|
||||||
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||||
|
SELECT $1, $2, 'involves', '{"by":"nomos"}'::jsonb, now()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM relationships
|
||||||
|
WHERE source_id = $1 AND target_id = $2 AND type = 'involves' AND valid_to IS NULL)`,
|
||||||
|
taskEntityID, f.id)
|
||||||
|
// Live pulse for the panel. correlation_id = sessionID lets the frontend
|
||||||
|
// filter to the active task.
|
||||||
|
_ = observability.Event(ctx, q, "entity.touched", &f.id, "info", "nomos", sessionID,
|
||||||
|
map[string]any{"slug": slug, "tool": toolName})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// collectTaskSlugs recursively pulls entity slugs out of tool-call args,
|
||||||
|
// mirroring the frontend's collectSlugs so both sides see the same references.
|
||||||
|
func collectTaskSlugs(v any, out map[string]struct{}) {
|
||||||
|
switch t := v.(type) {
|
||||||
|
case string:
|
||||||
|
for _, m := range taskSlugRe.FindAllString(t, -1) {
|
||||||
|
out[strings.TrimRight(m, ".,;)]")] = struct{}{}
|
||||||
|
}
|
||||||
|
case []any:
|
||||||
|
for _, e := range t {
|
||||||
|
collectTaskSlugs(e, out)
|
||||||
|
}
|
||||||
|
case map[string]any:
|
||||||
|
for _, e := range t {
|
||||||
|
collectTaskSlugs(e, out)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// pendingContinuation is one finished execution whose result hasn't yet been
|
// pendingContinuation is one finished execution whose result hasn't yet been
|
||||||
// fed back to its originating session.
|
// fed back to its originating session.
|
||||||
type pendingContinuation struct {
|
type pendingContinuation struct {
|
||||||
@@ -286,59 +940,74 @@ func (s *store) markContinued(ctx context.Context, execID uuid.UUID) {
|
|||||||
s.pool.Exec(ctx, `UPDATE nomos_plan_executions SET continued_at = now() WHERE execution_id = $1`, execID)
|
s.pool.Exec(ctx, `UPDATE nomos_plan_executions SET continued_at = now() WHERE execution_id = $1`, execID)
|
||||||
}
|
}
|
||||||
|
|
||||||
// assentWindowActive reports whether this agent currently has an open assent
|
// assentWindowActive reports whether THIS TASK currently has an open assent
|
||||||
// window — the scope gate for auto-continuation. We only auto-continue
|
// window — the scope gate for auto-continuation. Scoped by session, not just
|
||||||
// executions that are part of an approved plan, never stray one-off actions.
|
// agent: with a single agent:nomos entity serving every concurrent task, an
|
||||||
func (s *store) assentWindowActive(ctx context.Context, agentID uuid.UUID) bool {
|
// agent-only key would let approving Task A's plan silently auto-run
|
||||||
if s == nil || agentID == uuid.Nil {
|
// unapproved config-mutation actions in a concurrently-running Task B. We
|
||||||
return false
|
// only auto-continue executions that are part of THIS session's approved
|
||||||
|
// plan, never a stray action from another task riding the same window.
|
||||||
|
func (s *store) assentWindowActive(ctx context.Context, agentID uuid.UUID, sessionID string) bool {
|
||||||
|
if s == nil || agentID == uuid.Nil || sessionID == "" {
|
||||||
|
return false // fail closed: no session to scope to means no window
|
||||||
}
|
}
|
||||||
var expires time.Time
|
var expires time.Time
|
||||||
key := "assent_window.agent:" + agentID.String()
|
key := assentWindowKey(agentID, sessionID)
|
||||||
if err := s.pool.QueryRow(ctx, `SELECT value::timestamptz FROM autonomy_settings WHERE key = $1`, key).Scan(&expires); err != nil {
|
if err := s.pool.QueryRow(ctx, `SELECT value::timestamptz FROM autonomy_settings WHERE key = $1`, key).Scan(&expires); err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return time.Now().Before(expires)
|
return time.Now().Before(expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// assentWindowKey scopes the grant to one agent AND one session/task — see
|
||||||
|
// assentWindowActive. Must match internal/mcp/server.go's copy (mirrored
|
||||||
|
// there, not shared, since the two are separate Go packages/binaries reading
|
||||||
|
// the same autonomy_settings row).
|
||||||
|
func assentWindowKey(agentID uuid.UUID, sessionID string) string {
|
||||||
|
return "assent_window.agent:" + agentID.String() + ".session:" + sessionID
|
||||||
|
}
|
||||||
|
|
||||||
// destructiveWindowDuration is intentionally shorter than the general assent
|
// destructiveWindowDuration is intentionally shorter than the general assent
|
||||||
// window (30 min): it's a narrow, scoped grant for a multi-step DESTRUCTIVE
|
// window (30 min): it's a narrow, scoped grant for a multi-step DESTRUCTIVE
|
||||||
// recovery (e.g. "stop then destroy this specific half-provisioned
|
// recovery (e.g. "stop then destroy this specific half-provisioned
|
||||||
// container"), not a standing license to destroy things.
|
// container"), not a standing license to destroy things.
|
||||||
const destructiveWindowDuration = 15 * time.Minute
|
const destructiveWindowDuration = 15 * time.Minute
|
||||||
|
|
||||||
// destructiveWindowKey scopes the grant to one agent AND one target entity —
|
// destructiveWindowKey scopes the grant to one agent, one target entity, AND
|
||||||
// an explicit typed confirmation ("I confirm") for a destructive action on
|
// one session/task — an explicit typed confirmation ("I confirm") for a
|
||||||
// target X must never be read as authorizing a destructive action on target Y.
|
// destructive action on target X in task A must never be read as authorizing
|
||||||
func destructiveWindowKey(agentID uuid.UUID, targetSlug string) string {
|
// a destructive action on target X from a DIFFERENT concurrently-running
|
||||||
return "destructive_window.agent:" + agentID.String() + ".target:" + targetSlug
|
// task B, even though both share the same agent identity.
|
||||||
|
func destructiveWindowKey(agentID uuid.UUID, targetSlug, sessionID string) string {
|
||||||
|
return "destructive_window.agent:" + agentID.String() + ".target:" + targetSlug + ".session:" + sessionID
|
||||||
}
|
}
|
||||||
|
|
||||||
// openDestructiveWindow records a short, target-scoped grant after an
|
// openDestructiveWindow records a short, target-and-session-scoped grant
|
||||||
// operator's EXPLICIT typed confirmation (never loose assent) authorized a
|
// after an operator's EXPLICIT typed confirmation (never loose assent)
|
||||||
// destructive action. Real case this exists for: recovering a failed destroy
|
// authorized a destructive action. Real case this exists for: recovering a
|
||||||
// took "stop" (destructive) then "destroy" (destructive) — same container,
|
// failed destroy took "stop" (destructive) then "destroy" (destructive) —
|
||||||
// two separate typed-confirmation round trips, because each was gated
|
// same container, two separate typed-confirmation round trips, because each
|
||||||
// independently. One explicit confirmation on a target should cover the
|
// was gated independently. One explicit confirmation on a target should
|
||||||
// short follow-up sequence needed to finish what was just confirmed.
|
// cover the short follow-up sequence needed to finish what was just
|
||||||
func (s *store) openDestructiveWindow(ctx context.Context, agentID uuid.UUID, targetSlug string) {
|
// confirmed — but only within the task that got the confirmation.
|
||||||
if s == nil || agentID == uuid.Nil || targetSlug == "" {
|
func (s *store) openDestructiveWindow(ctx context.Context, agentID uuid.UUID, targetSlug, sessionID string) {
|
||||||
|
if s == nil || agentID == uuid.Nil || targetSlug == "" || sessionID == "" {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
expires := time.Now().Add(destructiveWindowDuration).UTC().Format(time.RFC3339)
|
expires := time.Now().Add(destructiveWindowDuration).UTC().Format(time.RFC3339)
|
||||||
s.pool.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
|
s.pool.Exec(ctx, `INSERT INTO autonomy_settings (key, value) VALUES ($1, $2)
|
||||||
ON CONFLICT (key) DO UPDATE SET value = $2`, destructiveWindowKey(agentID, targetSlug), expires)
|
ON CONFLICT (key) DO UPDATE SET value = $2`, destructiveWindowKey(agentID, targetSlug, sessionID), expires)
|
||||||
}
|
}
|
||||||
|
|
||||||
// destructiveWindowActive reports whether target has a live, explicitly-
|
// destructiveWindowActive reports whether target has a live, explicitly-
|
||||||
// confirmed destructive grant for this agent.
|
// confirmed destructive grant for this agent within this session/task.
|
||||||
func (s *store) destructiveWindowActive(ctx context.Context, agentID uuid.UUID, targetSlug string) bool {
|
func (s *store) destructiveWindowActive(ctx context.Context, agentID uuid.UUID, targetSlug, sessionID string) bool {
|
||||||
if s == nil || agentID == uuid.Nil || targetSlug == "" {
|
if s == nil || agentID == uuid.Nil || targetSlug == "" || sessionID == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
var expires time.Time
|
var expires time.Time
|
||||||
if err := s.pool.QueryRow(ctx, `SELECT value::timestamptz FROM autonomy_settings WHERE key = $1`,
|
if err := s.pool.QueryRow(ctx, `SELECT value::timestamptz FROM autonomy_settings WHERE key = $1`,
|
||||||
destructiveWindowKey(agentID, targetSlug)).Scan(&expires); err != nil {
|
destructiveWindowKey(agentID, targetSlug, sessionID)).Scan(&expires); err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
return time.Now().Before(expires)
|
return time.Now().Before(expires)
|
||||||
@@ -358,18 +1027,59 @@ func (s *store) executionTarget(ctx context.Context, execID uuid.UUID) string {
|
|||||||
return slug
|
return slug
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// entityArgKeys lists tool-argument keys, in priority order, that commonly
|
||||||
|
// carry the target entity's slug or UUID. Tool input schemas aren't
|
||||||
|
// consistent about naming this (target, entity_slug, slug, service_slug,
|
||||||
|
// lxc_slug, entity_id all appear across the MCP tool registrations in
|
||||||
|
// internal/mcp/server.go), so this is a best-effort lookup used to tag
|
||||||
|
// agent_activity rows with the entity a tool call acted on.
|
||||||
|
var entityArgKeys = []string{
|
||||||
|
"target", "entity_slug", "slug", "slug_or_id",
|
||||||
|
"service_slug", "lxc_slug", "entity_id", "about",
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveArgEntityID best-effort resolves the entity a tool call acted on
|
||||||
|
// from its arguments, trying entityArgKeys in order. Returns uuid.Nil if no
|
||||||
|
// key is present or none resolves to a known entity.
|
||||||
|
func (s *store) resolveArgEntityID(ctx context.Context, args map[string]any) uuid.UUID {
|
||||||
|
if s == nil {
|
||||||
|
return uuid.Nil
|
||||||
|
}
|
||||||
|
for _, key := range entityArgKeys {
|
||||||
|
v, _ := args[key].(string)
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if u, err := uuid.Parse(v); err == nil {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
var id uuid.UUID
|
||||||
|
if err := s.pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", v).Scan(&id); err == nil {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uuid.Nil
|
||||||
|
}
|
||||||
|
|
||||||
// logActivity records a tool call. agent_id is the agent entity UUID and is
|
// logActivity records a tool call. agent_id is the agent entity UUID and is
|
||||||
// NOT NULL in the schema, so we skip logging when it can't be resolved.
|
// NOT NULL in the schema, so we skip logging when it can't be resolved.
|
||||||
// The (nullable) session_id column carries the conversation id.
|
// The (nullable) session_id column carries the conversation id. args is the
|
||||||
func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName, inputSummary, outputSummary string, durationMs int, success bool, correlationID string) {
|
// tool call's own arguments, used to best-effort tag the row with the
|
||||||
|
// entity it acted on (see resolveArgEntityID).
|
||||||
|
func (s *store) logActivity(ctx context.Context, agentID uuid.UUID, sessionID, toolName string, args map[string]any, inputSummary, outputSummary string, durationMs int, success bool, correlationID string) {
|
||||||
if s == nil || agentID == uuid.Nil {
|
if s == nil || agentID == uuid.Nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
entityID := s.resolveArgEntityID(ctx, args)
|
||||||
|
var entityIDArg any
|
||||||
|
if entityID != uuid.Nil {
|
||||||
|
entityIDArg = entityID
|
||||||
|
}
|
||||||
s.pool.Exec(ctx, `
|
s.pool.Exec(ctx, `
|
||||||
INSERT INTO agent_activity
|
INSERT INTO agent_activity
|
||||||
(agent_id, session_id, activity_type, tool_name, input_summary, output_summary,
|
(agent_id, session_id, activity_type, tool_name, entity_id, input_summary, output_summary,
|
||||||
duration_ms, success, correlation_id)
|
duration_ms, success, correlation_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)`,
|
||||||
agentID, sessionID, "tool_call", toolName, inputSummary, outputSummary,
|
agentID, sessionID, "tool_call", toolName, entityIDArg, inputSummary, outputSummary,
|
||||||
durationMs, success, correlationID)
|
durationMs, success, correlationID)
|
||||||
}
|
}
|
||||||
|
|||||||
238
cmd/nomos/store_test.go
Normal file
238
cmd/nomos/store_test.go
Normal file
@@ -0,0 +1,238 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
// Integration tests against a real Postgres, mirroring
|
||||||
|
// internal/db/integration_test.go's pattern: guarded by
|
||||||
|
// OIKOS_TEST_DATABASE_URL (skipped when unset), throwaway database per run,
|
||||||
|
// full migrations applied, dropped on cleanup. Run with:
|
||||||
|
//
|
||||||
|
// docker compose up -d postgres
|
||||||
|
// OIKOS_TEST_DATABASE_URL="postgres://oikos:oikos_dev@localhost:5432/oikos?sslmode=disable" go test ./cmd/nomos/
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"math/rand"
|
||||||
|
"os"
|
||||||
|
"strings"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/dtoro/oikos/internal/db"
|
||||||
|
"github.com/jackc/pgx/v5"
|
||||||
|
)
|
||||||
|
|
||||||
|
// newTestStore creates a throwaway, fully-migrated database and returns a
|
||||||
|
// *store connected to it, cleaned up (including a matching task:<session>
|
||||||
|
// entity type in the ontology, needed by createTaskEntity/proposePlan tests)
|
||||||
|
// via t.Cleanup.
|
||||||
|
func newTestStore(t *testing.T) *store {
|
||||||
|
t.Helper()
|
||||||
|
baseURL := os.Getenv("OIKOS_TEST_DATABASE_URL")
|
||||||
|
if baseURL == "" {
|
||||||
|
t.Skip("OIKOS_TEST_DATABASE_URL not set — skipping integration test")
|
||||||
|
}
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
admin, err := pgx.Connect(ctx, baseURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect admin: %v", err)
|
||||||
|
}
|
||||||
|
dbName := fmt.Sprintf("oikos_test_nomos_%08x", rand.Int63())
|
||||||
|
if _, err := admin.Exec(ctx, "CREATE DATABASE "+dbName); err != nil {
|
||||||
|
admin.Close(ctx)
|
||||||
|
t.Fatalf("create test db: %v", err)
|
||||||
|
}
|
||||||
|
admin.Close(ctx)
|
||||||
|
|
||||||
|
testURL := swapTestDatabase(baseURL, dbName)
|
||||||
|
pool, err := db.New(ctx, testURL)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("connect test db: %v", err)
|
||||||
|
}
|
||||||
|
t.Cleanup(func() {
|
||||||
|
pool.Close()
|
||||||
|
admin, err := pgx.Connect(ctx, baseURL)
|
||||||
|
if err == nil {
|
||||||
|
admin.Exec(ctx, "DROP DATABASE IF EXISTS "+dbName+" WITH (FORCE)")
|
||||||
|
admin.Close(ctx)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
if err := pool.Migrate(ctx); err != nil {
|
||||||
|
t.Fatalf("migrate: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// session_plan_steps/session_questions tests don't need the ontology
|
||||||
|
// seed, but createTaskEntity's INSERT INTO entities (type='task') has an
|
||||||
|
// FK to entity_types — seed the minimal rows it needs directly rather
|
||||||
|
// than pulling in the full seeds/ontology.yaml ingest path.
|
||||||
|
if _, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO entity_types (name, domain, layer) VALUES ('entity', 'meta', 'meta')
|
||||||
|
ON CONFLICT DO NOTHING;
|
||||||
|
INSERT INTO entity_types (name, parent_type, domain, layer) VALUES ('task', 'entity', 'cognition', 'cognition')
|
||||||
|
ON CONFLICT DO NOTHING;`); err != nil {
|
||||||
|
t.Fatalf("seed minimal ontology: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &store{pool: pool.Pool}
|
||||||
|
}
|
||||||
|
|
||||||
|
func swapTestDatabase(url, dbName string) string {
|
||||||
|
qi := strings.Index(url, "?")
|
||||||
|
params, base := "", url
|
||||||
|
if qi >= 0 {
|
||||||
|
params = url[qi:]
|
||||||
|
base = url[:qi]
|
||||||
|
}
|
||||||
|
si := strings.LastIndex(base, "/")
|
||||||
|
return base[:si+1] + dbName + params
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGetRecentMessages_Truncation is the concrete proof for fix A2 of
|
||||||
|
// plans/2026-07-11-nomos-agent-code-review.md: chatWith used to replay a
|
||||||
|
// session's ENTIRE history on every turn with no bound. getRecentMessages
|
||||||
|
// caps that; this test checks both sides — under the limit, nothing is
|
||||||
|
// dropped and truncated=false; over it, only the most recent `limit` come
|
||||||
|
// back, in chronological order, with truncated=true.
|
||||||
|
func TestGetRecentMessages_Truncation(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
sess, err := s.createSession(ctx, "history window test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
const total = 35
|
||||||
|
const limit = 30
|
||||||
|
for i := 0; i < total; i++ {
|
||||||
|
role := "user"
|
||||||
|
if i%2 == 1 {
|
||||||
|
role = "assistant"
|
||||||
|
}
|
||||||
|
body := fmt.Appendf(nil, `{"role":%q,"text":"msg-%d"}`, role, i)
|
||||||
|
if err := s.saveMessage(ctx, sess.ID, role, body); err != nil {
|
||||||
|
t.Fatalf("saveMessage %d: %v", i, err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
msgs, truncated, err := s.getRecentMessages(ctx, sess.ID, limit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getRecentMessages: %v", err)
|
||||||
|
}
|
||||||
|
if !truncated {
|
||||||
|
t.Errorf("truncated = false, want true (%d messages > limit %d)", total, limit)
|
||||||
|
}
|
||||||
|
if len(msgs) != limit {
|
||||||
|
t.Fatalf("got %d messages, want %d", len(msgs), limit)
|
||||||
|
}
|
||||||
|
// Chronological order: the oldest of the RETAINED messages should be the
|
||||||
|
// (total-limit)-th one saved (msg-5, since msg-0..4 were dropped), and
|
||||||
|
// the last should be the most recently saved (msg-34).
|
||||||
|
wantFirst := fmt.Sprintf("msg-%d", total-limit)
|
||||||
|
wantLast := fmt.Sprintf("msg-%d", total-1)
|
||||||
|
if got := extractText(msgs[0].Content); got != wantFirst {
|
||||||
|
t.Errorf("first retained message = %q, want %q", got, wantFirst)
|
||||||
|
}
|
||||||
|
if got := extractText(msgs[len(msgs)-1].Content); got != wantLast {
|
||||||
|
t.Errorf("last retained message = %q, want %q", got, wantLast)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Under the limit: nothing dropped.
|
||||||
|
sess2, err := s.createSession(ctx, "small session")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createSession: %v", err)
|
||||||
|
}
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
body := fmt.Appendf(nil, `{"role":"user","text":"msg-%d"}`, i)
|
||||||
|
if err := s.saveMessage(ctx, sess2.ID, "user", body); err != nil {
|
||||||
|
t.Fatalf("saveMessage: %v", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
msgs2, truncated2, err := s.getRecentMessages(ctx, sess2.ID, limit)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getRecentMessages (small): %v", err)
|
||||||
|
}
|
||||||
|
if truncated2 {
|
||||||
|
t.Errorf("truncated = true for a 5-message session under a %d limit, want false", limit)
|
||||||
|
}
|
||||||
|
if len(msgs2) != 5 {
|
||||||
|
t.Errorf("got %d messages, want 5", len(msgs2))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestProposePlan_AppendVsReplace is the concrete proof for the plan-append
|
||||||
|
// fix (commit 5384499, "plan panel showed only the latest step, not the full
|
||||||
|
// plan"): proposePlan must REPLACE the step list only while every existing
|
||||||
|
// step is still 'pending' (a genuine pre-execution revision), and APPEND
|
||||||
|
// once any step has started — otherwise a model that calls propose_plan once
|
||||||
|
// per step (rather than once with the full list, as instructed) erases every
|
||||||
|
// already-completed step each time, and the operator only ever sees the
|
||||||
|
// latest single step instead of real progress.
|
||||||
|
func TestProposePlan_AppendVsReplace(t *testing.T) {
|
||||||
|
s := newTestStore(t)
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
sess, err := s.createSession(ctx, "plan append test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createSession: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// First call: no steps exist yet — must persist as-is (replace mode,
|
||||||
|
// trivially: nothing to replace).
|
||||||
|
out1, err := s.proposePlan(ctx, sess.ID, []planStepInput{{Title: "Step A"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("proposePlan #1: %v", err)
|
||||||
|
}
|
||||||
|
if len(out1) != 1 || out1[0]["seq"] != 1 {
|
||||||
|
t.Fatalf("proposePlan #1 = %+v, want one step at seq 1", out1)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Mark step 1 as started.
|
||||||
|
if err := s.updatePlanStep(ctx, sess.ID, 1, "running", ""); err != nil {
|
||||||
|
t.Fatalf("updatePlanStep: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Second call, simulating a model that (against instructions) calls
|
||||||
|
// propose_plan again per-step instead of once with the full list: since
|
||||||
|
// step 1 has left 'pending', this MUST append, not replace.
|
||||||
|
out2, err := s.proposePlan(ctx, sess.ID, []planStepInput{{Title: "Step B"}})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("proposePlan #2: %v", err)
|
||||||
|
}
|
||||||
|
if len(out2) != 1 || out2[0]["seq"] != 2 {
|
||||||
|
t.Fatalf("proposePlan #2 = %+v, want one step at seq 2 (appended after the running step 1)", out2)
|
||||||
|
}
|
||||||
|
|
||||||
|
steps, err := s.getPlanSteps(ctx, sess.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getPlanSteps: %v", err)
|
||||||
|
}
|
||||||
|
if len(steps) != 2 {
|
||||||
|
t.Fatalf("got %d persisted steps, want 2 (step 1 must survive the second propose_plan call)", len(steps))
|
||||||
|
}
|
||||||
|
if steps[0].Title != "Step A" || steps[0].Status != "running" {
|
||||||
|
t.Errorf("step 1 = %+v, want Step A still running (not erased)", steps[0])
|
||||||
|
}
|
||||||
|
if steps[1].Title != "Step B" || steps[1].Status != "pending" {
|
||||||
|
t.Errorf("step 2 = %+v, want Step B pending", steps[1])
|
||||||
|
}
|
||||||
|
|
||||||
|
// Third call BEFORE anything runs on a fresh session: every step is
|
||||||
|
// still pending, so this must REPLACE, not append.
|
||||||
|
sess2, err := s.createSession(ctx, "plan replace test")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("createSession: %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.proposePlan(ctx, sess2.ID, []planStepInput{{Title: "Original"}}); err != nil {
|
||||||
|
t.Fatalf("proposePlan (initial): %v", err)
|
||||||
|
}
|
||||||
|
if _, err := s.proposePlan(ctx, sess2.ID, []planStepInput{{Title: "Revised"}}); err != nil {
|
||||||
|
t.Fatalf("proposePlan (revise before execution): %v", err)
|
||||||
|
}
|
||||||
|
revisedSteps, err := s.getPlanSteps(ctx, sess2.ID)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("getPlanSteps: %v", err)
|
||||||
|
}
|
||||||
|
if len(revisedSteps) != 1 || revisedSteps[0].Title != "Revised" {
|
||||||
|
t.Fatalf("got %+v, want a single 'Revised' step (pre-execution revise must replace, not append)", revisedSteps)
|
||||||
|
}
|
||||||
|
}
|
||||||
293
cmd/nomos/tasks.go
Normal file
293
cmd/nomos/tasks.go
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
package main
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"fmt"
|
||||||
|
"log/slog"
|
||||||
|
"strings"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Task tools are nomos-LOCAL, not MCP tools. They are session-scoped, and the
|
||||||
|
// shared MCP server (api:8090/mcp) has no session id — so these are handled
|
||||||
|
// in-process by nomos, which knows the session/task and holds the store.
|
||||||
|
// buildTools appends these to the model's tool list; the agent loop routes a
|
||||||
|
// call whose name isTaskTool to handleTaskTool instead of the MCP client.
|
||||||
|
//
|
||||||
|
// Phase 3 ships complete_task; set_goal / propose_plan / update_plan_step /
|
||||||
|
// ask_operator land in later phases through the same mechanism.
|
||||||
|
|
||||||
|
func taskToolDefs() []toolDef {
|
||||||
|
return []toolDef{
|
||||||
|
{
|
||||||
|
Name: "set_goal",
|
||||||
|
Description: "State the goal of this task in one sentence, as early as you " +
|
||||||
|
"can. This is what the task is trying to achieve (e.g. 'Deploy TypeType " +
|
||||||
|
"as an LXC on strong'); it heads the task on the board and the context " +
|
||||||
|
"panel. Call it once you understand what the operator wants.",
|
||||||
|
InputSchema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"goal": map[string]any{"type": "string", "description": "The task's goal, one sentence."},
|
||||||
|
},
|
||||||
|
"required": []string{"goal"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "propose_plan",
|
||||||
|
Description: "Lay out ALL the ordered steps you'll take to reach the goal, in ONE " +
|
||||||
|
"call, listing every step end-to-end — not just the next one. The operator " +
|
||||||
|
"sees the full list in the context panel and watches it progress; a plan " +
|
||||||
|
"with only 1 step looks broken to them even if you intend to add more later. " +
|
||||||
|
"Your FIRST step should be research (prior knowledge, relations, blast radius " +
|
||||||
|
"— not just this target's status) and your LAST step should be writing back " +
|
||||||
|
"what you learned (update_entity_attributes / create_relationship / " +
|
||||||
|
"upsert_knowledge) BEFORE complete_task — this is what keeps the knowledge " +
|
||||||
|
"graph from drifting out of date. " +
|
||||||
|
"Call this ONCE, before you start executing (after gathering what you need). " +
|
||||||
|
"As you work, call update_plan_step (not propose_plan again) to advance each " +
|
||||||
|
"step. Only re-call propose_plan if the plan itself has fundamentally changed " +
|
||||||
|
"(e.g. a new approach is needed) — in that case new steps are appended after " +
|
||||||
|
"whatever already ran, never erasing completed work.",
|
||||||
|
InputSchema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"steps": map[string]any{
|
||||||
|
"type": "array",
|
||||||
|
"description": "Ordered steps, first to last.",
|
||||||
|
"items": map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"title": map[string]any{"type": "string", "description": "Short imperative step title (e.g. 'Create the LXC')."},
|
||||||
|
"detail": map[string]any{"type": "string", "description": "Optional one-line detail."},
|
||||||
|
"target_slug": map[string]any{"type": "string", "description": "Optional entity slug this step acts on (e.g. lxc:typetype)."},
|
||||||
|
},
|
||||||
|
"required": []string{"title"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"steps"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "update_plan_step",
|
||||||
|
Description: "Advance a plan step as you work it. Set status to 'running' when " +
|
||||||
|
"you start it (pass execution_id if the step queued a gated action, so " +
|
||||||
|
"the board can auto-close it when that finishes), then 'done' / 'failed' " +
|
||||||
|
"/ 'skipped' / 'blocked' when it resolves. Keeps the operator's progress " +
|
||||||
|
"view honest.",
|
||||||
|
InputSchema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"seq": map[string]any{"type": "integer", "description": "1-based step number from propose_plan."},
|
||||||
|
"status": map[string]any{"type": "string", "enum": []string{"running", "done", "failed", "skipped", "blocked"}, "description": "New status for the step."},
|
||||||
|
"execution_id": map[string]any{"type": "string", "description": "Optional execution UUID this step is running, so it auto-closes on completion."},
|
||||||
|
},
|
||||||
|
"required": []string{"seq", "status"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "ask_operator",
|
||||||
|
Description: "Ask the operator a question when you hit a real decision only " +
|
||||||
|
"they can make — an ambiguous target, a trade-off, missing information, " +
|
||||||
|
"or a destructive choice not already approved. This pins a structured " +
|
||||||
|
"question card in the context panel (with your options and the entities " +
|
||||||
|
"involved) and PAUSES the task until they answer; their answer resumes " +
|
||||||
|
"you automatically. Do NOT use it for things you can determine yourself " +
|
||||||
|
"with tools — only for genuine decisions.",
|
||||||
|
InputSchema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"prompt": map[string]any{"type": "string", "description": "The question, stated plainly."},
|
||||||
|
"why": map[string]any{"type": "string", "description": "Why you're asking / what's at stake."},
|
||||||
|
"options": map[string]any{
|
||||||
|
"type": "array", "items": map[string]any{"type": "string"},
|
||||||
|
"description": "The choices, if it's a pick-one decision.",
|
||||||
|
},
|
||||||
|
"context_entities": map[string]any{
|
||||||
|
"type": "array", "items": map[string]any{"type": "string"},
|
||||||
|
"description": "Entity slugs relevant to the decision (shown as chips).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"prompt"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
Name: "complete_task",
|
||||||
|
Description: "Mark the current task finished. Call this once the goal is " +
|
||||||
|
"verified done — or when you've genuinely failed or only partially " +
|
||||||
|
"succeeded. Sets the task's outcome and a one-line summary shown on the " +
|
||||||
|
"task board. Record what you learned with upsert_knowledge BEFORE " +
|
||||||
|
"completing, so future tasks on the same entities benefit.",
|
||||||
|
InputSchema: map[string]any{
|
||||||
|
"type": "object",
|
||||||
|
"properties": map[string]any{
|
||||||
|
"outcome": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"enum": []string{"success", "failure", "partial"},
|
||||||
|
"description": "Did the task achieve its goal?",
|
||||||
|
},
|
||||||
|
"summary": map[string]any{
|
||||||
|
"type": "string",
|
||||||
|
"description": "One line describing the result (shown on the task card).",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"required": []string{"outcome", "summary"},
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toInt coerces a JSON tool-arg number (float64 after unmarshal) to int.
|
||||||
|
func toInt(v any) int {
|
||||||
|
switch n := v.(type) {
|
||||||
|
case float64:
|
||||||
|
return int(n)
|
||||||
|
case int:
|
||||||
|
return n
|
||||||
|
default:
|
||||||
|
return 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// toStringSlice coerces a JSON tool-arg array to a non-empty []string.
|
||||||
|
func toStringSlice(v any) []string {
|
||||||
|
arr, ok := v.([]any)
|
||||||
|
if !ok {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
out := make([]string, 0, len(arr))
|
||||||
|
for _, e := range arr {
|
||||||
|
if s, ok := e.(string); ok && strings.TrimSpace(s) != "" {
|
||||||
|
out = append(out, s)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
// handleTaskTool executes a nomos-local task tool. Returns (result, true) if it
|
||||||
|
// handled the call, or (nil, false) if name is not a local task tool (so the
|
||||||
|
// caller forwards it to the MCP client).
|
||||||
|
func (a *agent) handleTaskTool(ctx context.Context, sessionID, name string, args map[string]any) (any, bool) {
|
||||||
|
switch name {
|
||||||
|
case "set_goal":
|
||||||
|
goal, _ := args["goal"].(string)
|
||||||
|
if strings.TrimSpace(goal) == "" {
|
||||||
|
return "error: set_goal needs a goal", true
|
||||||
|
}
|
||||||
|
if err := a.store.setGoal(ctx, sessionID, goal); err != nil {
|
||||||
|
return fmt.Sprintf("error setting goal: %v", err), true
|
||||||
|
}
|
||||||
|
return "Goal set: " + goal, true
|
||||||
|
|
||||||
|
case "propose_plan":
|
||||||
|
raw, _ := args["steps"].([]any)
|
||||||
|
var steps []planStepInput
|
||||||
|
for _, r := range raw {
|
||||||
|
m, ok := r.(map[string]any)
|
||||||
|
if !ok {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
title, _ := m["title"].(string)
|
||||||
|
if strings.TrimSpace(title) == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
detail, _ := m["detail"].(string)
|
||||||
|
target, _ := m["target_slug"].(string)
|
||||||
|
steps = append(steps, planStepInput{Title: title, Detail: detail, TargetSlug: target})
|
||||||
|
}
|
||||||
|
if len(steps) == 0 {
|
||||||
|
return "error: propose_plan needs at least one step with a title", true
|
||||||
|
}
|
||||||
|
persisted, err := a.store.proposePlan(ctx, sessionID, steps)
|
||||||
|
if err != nil {
|
||||||
|
return fmt.Sprintf("error proposing plan: %v", err), true
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Plan set: %d step(s). Execute them now, marking each with update_plan_step as you go.", len(persisted)), true
|
||||||
|
|
||||||
|
case "update_plan_step":
|
||||||
|
seq := toInt(args["seq"])
|
||||||
|
status, _ := args["status"].(string)
|
||||||
|
execID, _ := args["execution_id"].(string)
|
||||||
|
if seq <= 0 || status == "" {
|
||||||
|
return "error: update_plan_step needs seq (>=1) and status", true
|
||||||
|
}
|
||||||
|
if err := a.store.updatePlanStep(ctx, sessionID, seq, status, execID); err != nil {
|
||||||
|
return fmt.Sprintf("error updating step %d: %v", seq, err), true
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Step %d → %s", seq, status), true
|
||||||
|
|
||||||
|
case "ask_operator":
|
||||||
|
prompt, _ := args["prompt"].(string)
|
||||||
|
if strings.TrimSpace(prompt) == "" {
|
||||||
|
return "error: ask_operator needs a prompt", true
|
||||||
|
}
|
||||||
|
qctx := map[string]any{}
|
||||||
|
if why, _ := args["why"].(string); strings.TrimSpace(why) != "" {
|
||||||
|
qctx["why"] = why
|
||||||
|
}
|
||||||
|
if opts := toStringSlice(args["options"]); len(opts) > 0 {
|
||||||
|
qctx["options"] = opts
|
||||||
|
}
|
||||||
|
if ents := toStringSlice(args["context_entities"]); len(ents) > 0 {
|
||||||
|
qctx["entities"] = ents
|
||||||
|
}
|
||||||
|
if _, err := a.store.askOperator(ctx, sessionID, prompt, qctx); err != nil {
|
||||||
|
return fmt.Sprintf("error posting question: %v", err), true
|
||||||
|
}
|
||||||
|
return "Question posted to the operator; the task is paused until they answer. " +
|
||||||
|
"Do not continue or call more tools — end your turn now and wait for their answer.", true
|
||||||
|
|
||||||
|
case "complete_task":
|
||||||
|
outcome, _ := args["outcome"].(string)
|
||||||
|
summary, _ := args["summary"].(string)
|
||||||
|
switch outcome {
|
||||||
|
case "":
|
||||||
|
outcome = "success" // no outcome given at all — assume success, the common case
|
||||||
|
case "success", "failure", "partial":
|
||||||
|
// valid, use as-is
|
||||||
|
default:
|
||||||
|
// The tool schema declares an enum, but a weaker model (or a
|
||||||
|
// typo) can still send anything — an unrecognized value used to
|
||||||
|
// persist as-is, silently, with only "failure" special-cased
|
||||||
|
// (store.completeTask derives status='failed' from it; anything
|
||||||
|
// else became status='done' regardless of what the value
|
||||||
|
// actually said). Default to "partial" rather than silently
|
||||||
|
// treating an unrecognized value as "success" — safer to
|
||||||
|
// under-claim than over-claim a task's outcome.
|
||||||
|
slog.Warn("nomos: complete_task got an unrecognized outcome, defaulting to partial",
|
||||||
|
"session", sessionID, "outcome", outcome)
|
||||||
|
outcome = "partial"
|
||||||
|
}
|
||||||
|
if err := a.store.completeTask(ctx, sessionID, outcome, summary); err != nil {
|
||||||
|
return fmt.Sprintf("error completing task: %v", err), true
|
||||||
|
}
|
||||||
|
return fmt.Sprintf("Task marked %s: %s", outcome, summary), true
|
||||||
|
default:
|
||||||
|
return nil, false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// autoCompleteTrivialTask is the case-1 fix from
|
||||||
|
// plans/2026-07-11-task-completion-safety-net.md: a session that never
|
||||||
|
// called set_goal never framed itself as a structured task, so a turn that
|
||||||
|
// ends with a plain-text answer and no further tool calls IS the task
|
||||||
|
// ending — but the model consistently skips complete_task for exactly this
|
||||||
|
// case (confirmed live: 43/50 production sessions were a single trivial
|
||||||
|
// Q&A exchange, none of which ever reached a terminal status). Rather than
|
||||||
|
// leave agent_sessions.status stuck at its creation-time default forever,
|
||||||
|
// close it out mechanically here: no judgment call needed, since SOUL.md
|
||||||
|
// already treats a one-shot answered question as done by definition.
|
||||||
|
func (a *agent) autoCompleteTrivialTask(ctx context.Context, sessionID, responseText string) {
|
||||||
|
summary := strings.TrimSpace(responseText)
|
||||||
|
summary = strings.SplitN(summary, "\n", 2)[0] // first line only — the board shows one line
|
||||||
|
const maxLen = 120
|
||||||
|
if len(summary) > maxLen {
|
||||||
|
summary = summary[:maxLen] + "…"
|
||||||
|
}
|
||||||
|
if summary == "" {
|
||||||
|
summary = "Answered without further action needed."
|
||||||
|
}
|
||||||
|
if err := a.store.completeTask(ctx, sessionID, "success", summary); err != nil {
|
||||||
|
slog.Error("nomos: auto-complete trivial task failed", "session", sessionID, "error", err)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -8,6 +8,7 @@ import (
|
|||||||
"strconv"
|
"strconv"
|
||||||
|
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
|
"github.com/google/uuid"
|
||||||
)
|
)
|
||||||
|
|
||||||
// serveRecentKnowledge backs the Knowledge page's "what the system knows / has
|
// serveRecentKnowledge backs the Knowledge page's "what the system knows / has
|
||||||
@@ -111,7 +112,7 @@ func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledg
|
|||||||
limit := clampLimit(request.Params.Limit)
|
limit := clampLimit(request.Params.Limit)
|
||||||
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags,
|
SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags,
|
||||||
ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
|
ts_rank(ke.search, plainto_tsquery('english', $1)) AS rank,
|
||||||
ts_headline('english', ke.content, plainto_tsquery('english', $1),
|
ts_headline('english', ke.content, plainto_tsquery('english', $1),
|
||||||
'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3,
|
'MaxWords=40, MinWords=15, ShortWord=3, MaxFragments=3,
|
||||||
@@ -131,12 +132,13 @@ func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledg
|
|||||||
items := []gen.KnowledgeHit{}
|
items := []gen.KnowledgeHit{}
|
||||||
|
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
|
var id uuid.UUID
|
||||||
var slug, eType, title, source string
|
var slug, eType, title, source string
|
||||||
var tags []string
|
var tags []string
|
||||||
var rank float32
|
var rank float32
|
||||||
var snippet *string
|
var snippet *string
|
||||||
|
|
||||||
if err := rows.Scan(&slug, &eType, &title, &source, &tags, &rank, &snippet); err != nil {
|
if err := rows.Scan(&id, &slug, &eType, &title, &source, &tags, &rank, &snippet); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -149,6 +151,7 @@ func (s *Server) SearchKnowledge(ctx context.Context, request gen.SearchKnowledg
|
|||||||
}
|
}
|
||||||
|
|
||||||
items = append(items, gen.KnowledgeHit{
|
items = append(items, gen.KnowledgeHit{
|
||||||
|
Id: id,
|
||||||
Slug: slug,
|
Slug: slug,
|
||||||
Title: title,
|
Title: title,
|
||||||
Type: hitType,
|
Type: hitType,
|
||||||
@@ -172,7 +175,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
|
|||||||
entitySlug := request.EntityId
|
entitySlug := request.EntityId
|
||||||
|
|
||||||
rows, err := s.pool.Query(ctx, `
|
rows, err := s.pool.Query(ctx, `
|
||||||
SELECT e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
|
SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
|
||||||
FROM knowledge_entities ke
|
FROM knowledge_entities ke
|
||||||
JOIN entities e ON e.id = ke.entity_id
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
JOIN entity_types et ON et.name = e.type
|
JOIN entity_types et ON et.name = e.type
|
||||||
@@ -182,7 +185,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
|
|||||||
AND r.valid_to IS NULL
|
AND r.valid_to IS NULL
|
||||||
AND r.type IN ('documents', 'about')
|
AND r.type IN ('documents', 'about')
|
||||||
UNION
|
UNION
|
||||||
SELECT e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
|
SELECT e.id, e.slug, COALESCE(et.name,''), ke.title, ke.source, ke.tags
|
||||||
FROM knowledge_entities ke
|
FROM knowledge_entities ke
|
||||||
JOIN entities e ON e.id = ke.entity_id
|
JOIN entities e ON e.id = ke.entity_id
|
||||||
JOIN entity_types et ON et.name = e.type
|
JOIN entity_types et ON et.name = e.type
|
||||||
@@ -191,7 +194,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
|
|||||||
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
|
JOIN entities ent ON ent.type = target_type.name AND ent.slug = $1
|
||||||
WHERE r.valid_to IS NULL
|
WHERE r.valid_to IS NULL
|
||||||
AND r.type = 'procedure-for'
|
AND r.type = 'procedure-for'
|
||||||
ORDER BY 1`,
|
ORDER BY 2`,
|
||||||
entitySlug)
|
entitySlug)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
@@ -200,10 +203,11 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
|
|||||||
|
|
||||||
items := []gen.KnowledgeHit{}
|
items := []gen.KnowledgeHit{}
|
||||||
for rows.Next() {
|
for rows.Next() {
|
||||||
|
var id uuid.UUID
|
||||||
var slug, eType, title, source string
|
var slug, eType, title, source string
|
||||||
var tags []string
|
var tags []string
|
||||||
|
|
||||||
if err := rows.Scan(&slug, &eType, &title, &source, &tags); err != nil {
|
if err := rows.Scan(&id, &slug, &eType, &title, &source, &tags); err != nil {
|
||||||
return nil, err
|
return nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -216,6 +220,7 @@ func (s *Server) GetEntityKnowledge(ctx context.Context, request gen.GetEntityKn
|
|||||||
}
|
}
|
||||||
|
|
||||||
items = append(items, gen.KnowledgeHit{
|
items = append(items, gen.KnowledgeHit{
|
||||||
|
Id: id,
|
||||||
Slug: slug,
|
Slug: slug,
|
||||||
Title: title,
|
Title: title,
|
||||||
Type: hitType,
|
Type: hitType,
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import (
|
|||||||
"github.com/dtoro/oikos/internal/domain"
|
"github.com/dtoro/oikos/internal/domain"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
|
"github.com/dtoro/oikos/internal/safego"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/jackc/pgx/v5"
|
"github.com/jackc/pgx/v5"
|
||||||
"github.com/jackc/pgx/v5/pgtype"
|
"github.com/jackc/pgx/v5/pgtype"
|
||||||
@@ -117,6 +118,13 @@ func sshExec(ctx context.Context, host, user, command string) (string, error) {
|
|||||||
}
|
}
|
||||||
done := make(chan result, 1)
|
done := make(chan result, 1)
|
||||||
go func() {
|
go func() {
|
||||||
|
// See internal/mcp/server.go's sshExec for why this recovers rather
|
||||||
|
// than letting a rare SSH-library panic crash the whole api process.
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
done <- result{nil, fmt.Errorf("panic in ssh exec: %v", r)}
|
||||||
|
}
|
||||||
|
}()
|
||||||
out, err := session.CombinedOutput(command)
|
out, err := session.CombinedOutput(command)
|
||||||
done <- result{out, err}
|
done <- result{out, err}
|
||||||
}()
|
}()
|
||||||
@@ -231,6 +239,32 @@ func emitExecutionEvent(ctx context.Context, pool *db.Pool, execID uuid.UUID, st
|
|||||||
severity = "warning"
|
severity = "warning"
|
||||||
}
|
}
|
||||||
_ = observability.Event(ctx, sqlcgen.New(pool), "execution."+status, &execID, severity, "actuator", "", detail)
|
_ = observability.Event(ctx, sqlcgen.New(pool), "execution."+status, &execID, severity, "actuator", "", detail)
|
||||||
|
if status == "completed" || status == "failed" || status == "cancelled" {
|
||||||
|
closePlanStepForExecution(ctx, pool, execID, status)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// closePlanStepForExecution auto-closes a task plan step whose linked execution
|
||||||
|
// just reached a terminal state, so the task board advances even if the agent
|
||||||
|
// doesn't call update_plan_step itself (belt and suspenders — the agent links
|
||||||
|
// the step to the execution when it starts it; the api finishes it here). Emits
|
||||||
|
// plan.step.finished correlated to the step's session. No-op for the vast
|
||||||
|
// majority of executions, which aren't plan steps.
|
||||||
|
func closePlanStepForExecution(ctx context.Context, pool *db.Pool, execID uuid.UUID, execStatus string) {
|
||||||
|
stepStatus := "done"
|
||||||
|
if execStatus == "failed" || execStatus == "cancelled" {
|
||||||
|
stepStatus = "failed"
|
||||||
|
}
|
||||||
|
var stepID, sessionID string
|
||||||
|
var seq int
|
||||||
|
if err := pool.QueryRow(ctx, `
|
||||||
|
UPDATE session_plan_steps SET status = $2, finished_at = now()
|
||||||
|
WHERE execution_id = $1 AND status NOT IN ('done', 'failed', 'skipped')
|
||||||
|
RETURNING id::text, session_id::text, seq`, execID, stepStatus).Scan(&stepID, &sessionID, &seq); err != nil {
|
||||||
|
return // no matching open step
|
||||||
|
}
|
||||||
|
_ = observability.Event(ctx, sqlcgen.New(pool), "plan.step.finished", &execID, "info", "actuator", sessionID,
|
||||||
|
map[string]any{"step_id": stepID, "seq": seq, "status": stepStatus, "execution_id": execID.String()})
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, targetSlug string, actionStr string) {
|
func executeApprovedAction(ctx context.Context, pool *db.Pool, execID uuid.UUID, targetSlug string, actionStr string) {
|
||||||
@@ -1427,7 +1461,9 @@ func (s *Server) DecideApproval(ctx context.Context, req gen.DecideApprovalReque
|
|||||||
// Resolve target entity slug from targetID.
|
// Resolve target entity slug from targetID.
|
||||||
_ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug)
|
_ = tx.QueryRow(ctx, "SELECT slug FROM entities WHERE id = $1", targetID).Scan(&targetSlug)
|
||||||
|
|
||||||
go executeApprovedAction(context.Background(), s.pool, execID, targetSlug, actionStr)
|
safego.Go("httpapi:executeApprovedAction", func() {
|
||||||
|
executeApprovedAction(context.Background(), s.pool, execID, targetSlug, actionStr)
|
||||||
|
})
|
||||||
// Status only — risk_class was set correctly at request time
|
// Status only — risk_class was set correctly at request time
|
||||||
// (e.g. by policy.ClassifyCommand for `run`); overwriting it to
|
// (e.g. by policy.ClassifyCommand for `run`); overwriting it to
|
||||||
// a hardcoded 'config_mutation' here corrupted the audit ledger
|
// a hardcoded 'config_mutation' here corrupted the audit ledger
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import (
|
|||||||
"github.com/dtoro/oikos/internal/db"
|
"github.com/dtoro/oikos/internal/db"
|
||||||
"github.com/dtoro/oikos/internal/httpapi/gen"
|
"github.com/dtoro/oikos/internal/httpapi/gen"
|
||||||
mcphandler "github.com/dtoro/oikos/internal/mcp"
|
mcphandler "github.com/dtoro/oikos/internal/mcp"
|
||||||
|
"github.com/dtoro/oikos/internal/safego"
|
||||||
"github.com/go-chi/chi/v5"
|
"github.com/go-chi/chi/v5"
|
||||||
"github.com/go-chi/chi/v5/middleware"
|
"github.com/go-chi/chi/v5/middleware"
|
||||||
"github.com/golang-jwt/jwt/v5"
|
"github.com/golang-jwt/jwt/v5"
|
||||||
@@ -78,7 +79,10 @@ func NewHandler(ctx context.Context, pool *db.Pool, cfg config.Config, uiHandler
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Start background SSE listener, tied to ctx for clean shutdown.
|
// Start background SSE listener, tied to ctx for clean shutdown.
|
||||||
go s.sseListener(ctx)
|
// handleNotification (called per-message inside sseListener's loop) has
|
||||||
|
// its own recover for the common case; this outer one covers the
|
||||||
|
// connection-setup/reconnect code around it.
|
||||||
|
safego.Go("httpapi:sse-listener", func() { s.sseListener(ctx) })
|
||||||
|
|
||||||
r := chi.NewRouter()
|
r := chi.NewRouter()
|
||||||
r.Use(middleware.Recoverer)
|
r.Use(middleware.Recoverer)
|
||||||
@@ -531,6 +535,16 @@ func ListenAndServe(ctx context.Context, pool *db.Pool, cfg config.Config, uiHan
|
|||||||
|
|
||||||
errCh := make(chan error, 1)
|
errCh := make(chan error, 1)
|
||||||
go func() {
|
go func() {
|
||||||
|
// Recovers a panic in ListenAndServe (stdlib, so extremely unlikely,
|
||||||
|
// but an unrecovered panic here would crash the whole process rather
|
||||||
|
// than surfacing as a normal startup error) and reports it through
|
||||||
|
// errCh instead — the select below would otherwise just hang waiting
|
||||||
|
// for a value that never arrives.
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
errCh <- fmt.Errorf("panic in ListenAndServe: %v", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
slog.Info("api listening", "addr", cfg.APIListen)
|
slog.Info("api listening", "addr", cfg.APIListen)
|
||||||
errCh <- srv.ListenAndServe()
|
errCh <- srv.ListenAndServe()
|
||||||
}()
|
}()
|
||||||
|
|||||||
@@ -146,41 +146,58 @@ func (s *Server) sseListener(ctx context.Context) {
|
|||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
var p notifyPayload
|
s.handleNotification(ctx, nt.Payload)
|
||||||
if err := json.Unmarshal([]byte(nt.Payload), &p); err != nil {
|
|
||||||
slog.Error("sse listener unmarshal failed", "error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
|
|
||||||
// Fetch full event from DB
|
|
||||||
q := sqlcgen.New(s.pool)
|
|
||||||
events, err := q.ListEventsAfter(ctx, sqlcgen.ListEventsAfterParams{
|
|
||||||
ID: p.ID - 1,
|
|
||||||
Limit: 1,
|
|
||||||
})
|
|
||||||
if err != nil || len(events) == 0 {
|
|
||||||
slog.Warn("sse listener event fetch failed", "id", p.ID, "error", err)
|
|
||||||
continue
|
|
||||||
}
|
|
||||||
ev := events[0]
|
|
||||||
|
|
||||||
// Push to broker
|
|
||||||
s.sseBroker.push(ev)
|
|
||||||
|
|
||||||
// Fan out to subscribers (non-blocking send)
|
|
||||||
s.sseMu.Lock()
|
|
||||||
for sub := range s.sseSubs {
|
|
||||||
select {
|
|
||||||
case sub.ch <- ev:
|
|
||||||
default:
|
|
||||||
// Subscriber too slow — drop event for them
|
|
||||||
// (they'll reconnect via Last-Event-ID)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
s.sseMu.Unlock()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// handleNotification processes one pg_notify payload: decode, fetch the full
|
||||||
|
// event, push to the broker, fan out to live subscribers. Split out of
|
||||||
|
// sseListener's loop specifically so it can be wrapped in its own recover —
|
||||||
|
// a panic while handling ONE notification (a malformed payload, an
|
||||||
|
// unexpected nil somewhere in the fan-out) must not kill the whole listener
|
||||||
|
// goroutine, which would silently stop the live event stream for every
|
||||||
|
// connected client until the api process is restarted.
|
||||||
|
func (s *Server) handleNotification(ctx context.Context, payload string) {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
slog.Error("sse listener: panic recovered handling notification", "panic", r)
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
|
||||||
|
var p notifyPayload
|
||||||
|
if err := json.Unmarshal([]byte(payload), &p); err != nil {
|
||||||
|
slog.Error("sse listener unmarshal failed", "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fetch full event from DB
|
||||||
|
q := sqlcgen.New(s.pool)
|
||||||
|
events, err := q.ListEventsAfter(ctx, sqlcgen.ListEventsAfterParams{
|
||||||
|
ID: p.ID - 1,
|
||||||
|
Limit: 1,
|
||||||
|
})
|
||||||
|
if err != nil || len(events) == 0 {
|
||||||
|
slog.Warn("sse listener event fetch failed", "id", p.ID, "error", err)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
ev := events[0]
|
||||||
|
|
||||||
|
// Push to broker
|
||||||
|
s.sseBroker.push(ev)
|
||||||
|
|
||||||
|
// Fan out to subscribers (non-blocking send)
|
||||||
|
s.sseMu.Lock()
|
||||||
|
for sub := range s.sseSubs {
|
||||||
|
select {
|
||||||
|
case sub.ch <- ev:
|
||||||
|
default:
|
||||||
|
// Subscriber too slow — drop event for them
|
||||||
|
// (they'll reconnect via Last-Event-ID)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
s.sseMu.Unlock()
|
||||||
|
}
|
||||||
|
|
||||||
// sqlcEventToGen converts a DB event row to the canonical wire shape so the
|
// sqlcEventToGen converts a DB event row to the canonical wire shape so the
|
||||||
// SSE `data:` payload matches GET /events (snake_case keys, decoded data
|
// SSE `data:` payload matches GET /events (snake_case keys, decoded data
|
||||||
// object) rather than leaking Go field names and base64-encoded JSONB.
|
// object) rather than leaking Go field names and base64-encoded JSONB.
|
||||||
@@ -209,12 +226,22 @@ func sqlcEventToGen(ev sqlcgen.Event) gen.Event {
|
|||||||
// writeSSE writes a single Event as an SSE message. Returns false if the
|
// writeSSE writes a single Event as an SSE message. Returns false if the
|
||||||
// write failed (client disconnected). flusher may be nil (io.Pipe path,
|
// write failed (client disconnected). flusher may be nil (io.Pipe path,
|
||||||
// which has no separate flush step).
|
// which has no separate flush step).
|
||||||
|
//
|
||||||
|
// We deliberately DO NOT set the SSE `event:` name field, even though every
|
||||||
|
// event has a type. A named SSE event is only delivered to a matching
|
||||||
|
// addEventListener(type) handler, NOT to EventSource.onmessage — and the whole
|
||||||
|
// frontend (stores/events.ts and every page that reads liveEvents) consumes the
|
||||||
|
// stream via onmessage, reading the type from the JSON payload's `type` field.
|
||||||
|
// Emitting `event: <type>` silently routed every event away from onmessage, so
|
||||||
|
// the live stream delivered nothing to the UI. Leaving the name off sends all
|
||||||
|
// events to onmessage; the type is already in `data`, and new event types need
|
||||||
|
// zero client changes. `id:` is kept for Last-Event-ID reconnection.
|
||||||
func writeSSE(w ioWriter, flusher http.Flusher, ev sqlcgen.Event) bool {
|
func writeSSE(w ioWriter, flusher http.Flusher, ev sqlcgen.Event) bool {
|
||||||
data, err := json.Marshal(sqlcEventToGen(ev))
|
data, err := json.Marshal(sqlcEventToGen(ev))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return true // skip un-serializable events
|
return true // skip un-serializable events
|
||||||
}
|
}
|
||||||
_, err = fmt.Fprintf(w, "id: %d\nevent: %s\ndata: %s\n\n", ev.ID, ev.Type, data)
|
_, err = fmt.Fprintf(w, "id: %d\ndata: %s\n\n", ev.ID, data)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -24,6 +24,7 @@ import (
|
|||||||
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
"github.com/dtoro/oikos/internal/db/sqlcgen"
|
||||||
"github.com/dtoro/oikos/internal/observability"
|
"github.com/dtoro/oikos/internal/observability"
|
||||||
"github.com/dtoro/oikos/internal/policy"
|
"github.com/dtoro/oikos/internal/policy"
|
||||||
|
"github.com/dtoro/oikos/internal/safego"
|
||||||
"github.com/google/jsonschema-go/jsonschema"
|
"github.com/google/jsonschema-go/jsonschema"
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/modelcontextprotocol/go-sdk/mcp"
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
||||||
@@ -208,6 +209,69 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
return upsertKnowledge(ctx, pool, args)
|
return upsertKnowledge(ctx, pool, args)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
register(&mcp.Tool{Name: "update_entity_attributes", Description: "Merge new/changed attributes into an entity — the OTHER half of avoiding knowledge-base drift (upsert_knowledge records what you learned; this keeps the entity's own facts current). Use it when you discover something concrete about an entity's actual state that the graph doesn't reflect yet: a new IP, a version number, a config value, a discovered port — anything a FUTURE task would otherwise have to rediscover from scratch. Does NOT require approval (this updates the knowledge graph, not the live infrastructure). Merges shallowly — existing keys not mentioned are kept; keys you pass overwrite.",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"slug", "string", "Entity slug to update (e.g. lxc:typetype, host:strong)."},
|
||||||
|
prop{"attributes", "string", "JSON object string of attributes to merge in, e.g. {\"lan_ip\":\"192.168.8.50\",\"os\":\"debian-12\"}."},
|
||||||
|
),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
slug, _ := args["slug"].(string)
|
||||||
|
attrsStr, _ := args["attributes"].(string)
|
||||||
|
if slug == "" || attrsStr == "" {
|
||||||
|
return textResult("error: slug and attributes are required"), nil
|
||||||
|
}
|
||||||
|
var attrs map[string]any
|
||||||
|
if err := json.Unmarshal([]byte(attrsStr), &attrs); err != nil {
|
||||||
|
return textResult(fmt.Sprintf("error: attributes is not valid JSON: %v", err)), nil
|
||||||
|
}
|
||||||
|
attrsJSON, _ := json.Marshal(attrs)
|
||||||
|
ct, err := pool.Exec(ctx, `
|
||||||
|
UPDATE entities SET attributes = attributes || $2::jsonb, updated_at = now()
|
||||||
|
WHERE slug = $1`, slug, string(attrsJSON))
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("error updating %s: %v", slug, err)), nil
|
||||||
|
}
|
||||||
|
if ct.RowsAffected() == 0 {
|
||||||
|
return textResult(fmt.Sprintf("error: entity %q not found", slug)), nil
|
||||||
|
}
|
||||||
|
return textResult(fmt.Sprintf("Updated %s with %d attribute(s).", slug, len(attrs))), nil
|
||||||
|
})
|
||||||
|
|
||||||
|
register(&mcp.Tool{Name: "create_relationship", Description: "Record a relationship you discovered between two entities — the graph-structure half of keeping the knowledge base current (alongside update_entity_attributes and upsert_knowledge). Use it when you learn that one entity depends on, hosts, routes to, etc. another, and that edge isn't in the graph yet. type must be an existing relationship type (see get_relations output on similar entities for examples: hosts, provides, depends-on, configured-by, about, documents, ...). Idempotent — re-calling the same source/target/type is a no-op. Does NOT require approval.",
|
||||||
|
InputSchema: objSchema(
|
||||||
|
prop{"source", "string", "Source entity slug."},
|
||||||
|
prop{"target", "string", "Target entity slug."},
|
||||||
|
prop{"type", "string", "Relationship type name (must already exist in the ontology)."},
|
||||||
|
),
|
||||||
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
|
args := argsMap(req)
|
||||||
|
source, _ := args["source"].(string)
|
||||||
|
target, _ := args["target"].(string)
|
||||||
|
relType, _ := args["type"].(string)
|
||||||
|
if source == "" || target == "" || relType == "" {
|
||||||
|
return textResult("error: source, target, and type are required"), nil
|
||||||
|
}
|
||||||
|
var sourceID, targetID uuid.UUID
|
||||||
|
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", source).Scan(&sourceID); err != nil {
|
||||||
|
return textResult(fmt.Sprintf("error: source entity %q not found", source)), nil
|
||||||
|
}
|
||||||
|
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", target).Scan(&targetID); err != nil {
|
||||||
|
return textResult(fmt.Sprintf("error: target entity %q not found", target)), nil
|
||||||
|
}
|
||||||
|
_, err := pool.Exec(ctx, `
|
||||||
|
INSERT INTO relationships (source_id, target_id, type, attributes, valid_from)
|
||||||
|
SELECT $1, $2, $3, '{"by":"nomos"}'::jsonb, now()
|
||||||
|
WHERE NOT EXISTS (
|
||||||
|
SELECT 1 FROM relationships
|
||||||
|
WHERE source_id = $1 AND target_id = $2 AND type = $3 AND valid_to IS NULL)`,
|
||||||
|
sourceID, targetID, relType)
|
||||||
|
if err != nil {
|
||||||
|
return textResult(fmt.Sprintf("error creating relationship: %v (is %q a valid relationship type?)", err, relType)), nil
|
||||||
|
}
|
||||||
|
return textResult(fmt.Sprintf("Recorded: %s —%s→ %s", source, relType, target)), nil
|
||||||
|
})
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics",
|
register(&mcp.Tool{Name: "query_metrics", Description: "Query time-series metrics",
|
||||||
InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}),
|
InputSchema: objSchema(prop{"hours", "integer", "Look-back window in hours (default 24)"}),
|
||||||
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
}, func(ctx context.Context, req *mcp.CallToolRequest) (*mcp.CallToolResult, error) {
|
||||||
@@ -293,6 +357,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
targetSlug, _ := args["target"].(string)
|
targetSlug, _ := args["target"].(string)
|
||||||
action, _ := args["action"].(string)
|
action, _ := args["action"].(string)
|
||||||
params, _ := args["params"].(string)
|
params, _ := args["params"].(string)
|
||||||
|
sessionID, _ := args["_session_id"].(string)
|
||||||
if targetSlug == "" || action == "" {
|
if targetSlug == "" || action == "" {
|
||||||
return textResult("error: target and action required"), nil
|
return textResult("error: target and action required"), nil
|
||||||
}
|
}
|
||||||
@@ -325,7 +390,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
cmd = fmt.Sprintf("systemctl %s %s; sleep 1; systemctl is-active %s", params, svc, svc)
|
cmd = fmt.Sprintf("systemctl %s %s; sleep 1; systemctl is-active %s", params, svc, svc)
|
||||||
purpose = "systemctl " + params + " " + svc
|
purpose = "systemctl " + params + " " + svc
|
||||||
}
|
}
|
||||||
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, cmd, purpose, ""), nil
|
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, cmd, purpose, "", sessionID), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Deduplicate: if a pending execution already exists for the same
|
// Deduplicate: if a pending execution already exists for the same
|
||||||
@@ -388,7 +453,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
return textResult("apt audit:\n" + out), nil
|
return textResult("apt audit:\n" + out), nil
|
||||||
}
|
}
|
||||||
// During an active assent window, auto-approve.
|
// During an active assent window, auto-approve.
|
||||||
if assentWindowActive(ctx, pool, agentID) {
|
if assentWindowActive(ctx, pool, agentID, sessionID) {
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
|
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
|
||||||
createApproval(ctx, pool, id, targetID, "apt_upgrade", params, "config_mutation")
|
createApproval(ctx, pool, id, targetID, "apt_upgrade", params, "config_mutation")
|
||||||
// Do NOT pre-flip approvals/executions status here (that was
|
// Do NOT pre-flip approvals/executions status here (that was
|
||||||
@@ -410,7 +475,9 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
// tool call, cancelled the instant the chat turn's HTTP
|
// tool call, cancelled the instant the chat turn's HTTP
|
||||||
// response completes (every normal turn) — a goroutine
|
// response completes (every normal turn) — a goroutine
|
||||||
// meant to outlive the request must not inherit its context.
|
// meant to outlive the request must not inherit its context.
|
||||||
go executeApprovedViaAPI(context.Background(), id, targetSlug, "apt_upgrade:"+params)
|
safego.Go("mcp:executeApprovedViaAPI:apt_upgrade", func() {
|
||||||
|
executeApprovedViaAPI(context.Background(), id, targetSlug, "apt_upgrade:"+params)
|
||||||
|
})
|
||||||
slog.Info("mcp: apt_upgrade auto-approved via assent window", "execution_id", id)
|
slog.Info("mcp: apt_upgrade auto-approved via assent window", "execution_id", id)
|
||||||
return textResult(fmt.Sprintf("apt_upgrade on %s auto-approved via assent window — execution %s running.", targetSlug, id)), nil
|
return textResult(fmt.Sprintf("apt_upgrade on %s auto-approved via assent window — execution %s running.", targetSlug, id)), nil
|
||||||
}
|
}
|
||||||
@@ -422,13 +489,15 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
case "pct_create":
|
case "pct_create":
|
||||||
// During an active assent window, auto-approve and execute
|
// During an active assent window, auto-approve and execute
|
||||||
// instead of queuing — the operator already approved the plan.
|
// instead of queuing — the operator already approved the plan.
|
||||||
if assentWindowActive(ctx, pool, agentID) {
|
if assentWindowActive(ctx, pool, agentID, sessionID) {
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
|
pool.Exec(ctx, `UPDATE executions SET status='pending_approval', risk_class='config_mutation' WHERE entity_id=$1`, id)
|
||||||
createApproval(ctx, pool, id, targetID, "pct_create", params, "config_mutation")
|
createApproval(ctx, pool, id, targetID, "pct_create", params, "config_mutation")
|
||||||
// See the apt_upgrade case above for why there's no
|
// See the apt_upgrade case above for why there's no
|
||||||
// pre-flip-status "autoApprove" step here anymore, and why
|
// pre-flip-status "autoApprove" step here anymore, and why
|
||||||
// this uses context.Background().
|
// this uses context.Background().
|
||||||
go executeApprovedViaAPI(context.Background(), id, targetSlug, "pct_create:"+params)
|
safego.Go("mcp:executeApprovedViaAPI:pct_create", func() {
|
||||||
|
executeApprovedViaAPI(context.Background(), id, targetSlug, "pct_create:"+params)
|
||||||
|
})
|
||||||
slog.Info("mcp: pct_create auto-approved via assent window", "execution_id", id)
|
slog.Info("mcp: pct_create auto-approved via assent window", "execution_id", id)
|
||||||
return textResult(fmt.Sprintf("pct_create on %s auto-approved via assent window — execution %s running. The LXC is being provisioned now.", targetSlug, id)), nil
|
return textResult(fmt.Sprintf("pct_create on %s auto-approved via assent window — execution %s running. The LXC is being provisioned now.", targetSlug, id)), nil
|
||||||
}
|
}
|
||||||
@@ -454,6 +523,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
command, _ := args["command"].(string)
|
command, _ := args["command"].(string)
|
||||||
purpose, _ := args["purpose"].(string)
|
purpose, _ := args["purpose"].(string)
|
||||||
declaredRisk, _ := args["declared_risk"].(string)
|
declaredRisk, _ := args["declared_risk"].(string)
|
||||||
|
sessionID, _ := args["_session_id"].(string)
|
||||||
if targetSlug == "" || command == "" {
|
if targetSlug == "" || command == "" {
|
||||||
return textResult("error: target and command are required"), nil
|
return textResult("error: target and command are required"), nil
|
||||||
}
|
}
|
||||||
@@ -463,7 +533,7 @@ func newServer(pool *db.Pool, agentID uuid.UUID) *mcp.Server {
|
|||||||
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
|
return textResult(fmt.Sprintf("target not found: %s", targetSlug)), nil
|
||||||
}
|
}
|
||||||
|
|
||||||
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk), nil
|
return classifyAndGate(ctx, pool, agentID, targetID, targetSlug, command, purpose, declaredRisk, sessionID), nil
|
||||||
})
|
})
|
||||||
|
|
||||||
register(&mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.",
|
register(&mcp.Tool{Name: "http_get", Description: "Fetch a public web page or raw file (e.g. a GitHub README/raw URL) and return sanitized text. Use this to research how to deploy a service before provisioning. HTTP/HTTPS only; body is truncated to ~16KB.",
|
||||||
@@ -869,12 +939,18 @@ func withActivityLogging(pool *db.Pool, agentID uuid.UUID, toolName string, next
|
|||||||
|
|
||||||
correlationID := uuid.New().String()
|
correlationID := uuid.New().String()
|
||||||
|
|
||||||
|
entityID := resolveArgEntityID(ctx, pool, argsMap(req))
|
||||||
|
var entityIDArg any
|
||||||
|
if entityID != uuid.Nil {
|
||||||
|
entityIDArg = entityID
|
||||||
|
}
|
||||||
|
|
||||||
_, logErr := pool.Exec(ctx, `
|
_, logErr := pool.Exec(ctx, `
|
||||||
INSERT INTO agent_activity
|
INSERT INTO agent_activity
|
||||||
(agent_id, activity_type, tool_name, input_summary, output_summary,
|
(agent_id, activity_type, tool_name, entity_id, input_summary, output_summary,
|
||||||
duration_ms, success, correlation_id)
|
duration_ms, success, correlation_id)
|
||||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8)`,
|
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`,
|
||||||
agentID, "tool_call", toolName, inputSummary, outputSummary,
|
agentID, "tool_call", toolName, entityIDArg, inputSummary, outputSummary,
|
||||||
duration, success, correlationID)
|
duration, success, correlationID)
|
||||||
if logErr != nil {
|
if logErr != nil {
|
||||||
slog.Warn("mcp: log agent_activity", "error", logErr)
|
slog.Warn("mcp: log agent_activity", "error", logErr)
|
||||||
@@ -884,6 +960,37 @@ func withActivityLogging(pool *db.Pool, agentID uuid.UUID, toolName string, next
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// entityArgKeys lists tool-argument keys, in priority order, that commonly
|
||||||
|
// carry the target entity's slug or UUID. Tool input schemas aren't
|
||||||
|
// consistent about naming this (target, entity_slug, slug, service_slug,
|
||||||
|
// lxc_slug, entity_id all appear across server.go's tool registrations), so
|
||||||
|
// this is a best-effort lookup used to tag agent_activity rows with the
|
||||||
|
// entity a tool call acted on.
|
||||||
|
var entityArgKeys = []string{
|
||||||
|
"target", "entity_slug", "slug", "slug_or_id",
|
||||||
|
"service_slug", "lxc_slug", "entity_id", "about",
|
||||||
|
}
|
||||||
|
|
||||||
|
// resolveArgEntityID best-effort resolves the entity a tool call acted on
|
||||||
|
// from its arguments, trying entityArgKeys in order. Returns uuid.Nil if no
|
||||||
|
// key is present or none resolves to a known entity.
|
||||||
|
func resolveArgEntityID(ctx context.Context, pool *db.Pool, args map[string]any) uuid.UUID {
|
||||||
|
for _, key := range entityArgKeys {
|
||||||
|
v, _ := args[key].(string)
|
||||||
|
if v == "" {
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if u, err := uuid.Parse(v); err == nil {
|
||||||
|
return u
|
||||||
|
}
|
||||||
|
var id uuid.UUID
|
||||||
|
if err := pool.QueryRow(ctx, "SELECT id FROM entities WHERE slug = $1", v).Scan(&id); err == nil {
|
||||||
|
return id
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return uuid.Nil
|
||||||
|
}
|
||||||
|
|
||||||
// ─── Helpers ──────────────────────────────────────────────────────────
|
// ─── Helpers ──────────────────────────────────────────────────────────
|
||||||
|
|
||||||
func argsMap(req *mcp.CallToolRequest) map[string]any {
|
func argsMap(req *mcp.CallToolRequest) map[string]any {
|
||||||
@@ -1067,6 +1174,21 @@ func sshExec(ctx context.Context, host, user, command string) (string, error) {
|
|||||||
}
|
}
|
||||||
done := make(chan result, 1)
|
done := make(chan result, 1)
|
||||||
go func() {
|
go func() {
|
||||||
|
// Recovers a panic in CombinedOutput (SSH library internals, rare but
|
||||||
|
// not impossible) and reports it as a failed command instead of
|
||||||
|
// crashing the whole api process — every gated action runs through
|
||||||
|
// this function, so an unrecovered panic here would take down every
|
||||||
|
// concurrently-running task's execution, not just this one. Without
|
||||||
|
// this, a panic would ALSO silently degrade to "wait out the full
|
||||||
|
// timeout" (done never receives, the select below falls through to
|
||||||
|
// its time.After case) rather than crashing outright — recovering
|
||||||
|
// and sending an immediate result is strictly better: the caller
|
||||||
|
// finds out now, not after sshExecTimeout.
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
done <- result{nil, fmt.Errorf("panic in ssh exec: %v", r)}
|
||||||
|
}
|
||||||
|
}()
|
||||||
out, err := session.CombinedOutput(command)
|
out, err := session.CombinedOutput(command)
|
||||||
done <- result{out, err}
|
done <- result{out, err}
|
||||||
}()
|
}()
|
||||||
@@ -1245,7 +1367,7 @@ func resolveExecTarget(ctx context.Context, pool *db.Pool, targetSlug string) (h
|
|||||||
// fleet's reverse proxy) executed instantly with no approval at all. Routing
|
// fleet's reverse proxy) executed instantly with no approval at all. Routing
|
||||||
// every mutating path through the same classifier + approval-queue logic
|
// every mutating path through the same classifier + approval-queue logic
|
||||||
// closes that gap without special-casing each caller.
|
// closes that gap without special-casing each caller.
|
||||||
func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.UUID, targetSlug, command, purpose, declaredRisk string) *mcp.CallToolResult {
|
func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.UUID, targetSlug, command, purpose, declaredRisk, sessionID string) *mcp.CallToolResult {
|
||||||
riskClass := policy.ClassifyCommand(command, declaredRisk)
|
riskClass := policy.ClassifyCommand(command, declaredRisk)
|
||||||
runParams, _ := json.Marshal(map[string]string{"command": command, "purpose": purpose})
|
runParams, _ := json.Marshal(map[string]string{"command": command, "purpose": purpose})
|
||||||
actionCol := "run:" + string(runParams)
|
actionCol := "run:" + string(runParams)
|
||||||
@@ -1297,7 +1419,7 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
|
|||||||
// operator approved the overall direction; individual config steps
|
// operator approved the overall direction; individual config steps
|
||||||
// within the window don't each need a separate yes. Destructive
|
// within the window don't each need a separate yes. Destructive
|
||||||
// commands never auto-run, regardless of window.
|
// commands never auto-run, regardless of window.
|
||||||
if riskClass == policy.RiskConfigMutation && assentWindowActive(ctx, pool, agentID) {
|
if riskClass == policy.RiskConfigMutation && assentWindowActive(ctx, pool, agentID, sessionID) {
|
||||||
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
|
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s", rerr.Error()))
|
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s", rerr.Error()))
|
||||||
@@ -1319,7 +1441,7 @@ func classifyAndGate(ctx context.Context, pool *db.Pool, agentID, targetID uuid.
|
|||||||
// recovery (e.g. a failed destroy needing stop, then destroy) so the
|
// recovery (e.g. a failed destroy needing stop, then destroy) so the
|
||||||
// operator isn't asked to re-type "I confirm" for every single command
|
// operator isn't asked to re-type "I confirm" for every single command
|
||||||
// against the thing they just confirmed.
|
// against the thing they just confirmed.
|
||||||
if riskClass == policy.RiskDestructive && destructiveWindowActive(ctx, pool, agentID, targetSlug) {
|
if riskClass == policy.RiskDestructive && destructiveWindowActive(ctx, pool, agentID, targetSlug, sessionID) {
|
||||||
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
|
host, user, wrap, rerr := resolveExecTarget(ctx, pool, targetSlug)
|
||||||
if rerr != nil {
|
if rerr != nil {
|
||||||
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s", rerr.Error()))
|
pool.Exec(ctx, `UPDATE executions SET status='failed', result=$2::jsonb WHERE entity_id=$1`, id, jsonErr("%s", rerr.Error()))
|
||||||
@@ -1387,18 +1509,25 @@ func executeApprovedViaAPI(ctx context.Context, execID uuid.UUID, targetSlug, ac
|
|||||||
}
|
}
|
||||||
|
|
||||||
// assentWindowActive checks whether the operator has recently approved a plan
|
// assentWindowActive checks whether the operator has recently approved a plan
|
||||||
// in this agent's chat session. The agent sets an assent_window.agent:<uuid>
|
// in THIS TASK's chat session. The agent sets an
|
||||||
// key in autonomy_settings with an expiry timestamp when chat-assent grants
|
// assent_window.agent:<uuid>.session:<id> key in autonomy_settings with an
|
||||||
// a pending execution. While active, config_mutation commands auto-run
|
// expiry timestamp when chat-assent grants a pending execution. While
|
||||||
// without re-approval — the operator approved the overall plan, not each step.
|
// active, config_mutation commands auto-run without re-approval — the
|
||||||
func assentWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID) bool {
|
// operator approved the overall plan, not each step. Scoped by session, not
|
||||||
if agentID == uuid.Nil {
|
// just agent: with one agent:nomos entity serving every concurrent task, an
|
||||||
return false
|
// agent-only key would let approving Task A's plan silently auto-run
|
||||||
|
// unapproved actions from a concurrently-running Task B. sessionID comes
|
||||||
|
// from the `_session_id` nomos injects into every tool call's wire args
|
||||||
|
// (never part of any tool's declared InputSchema, so the model never
|
||||||
|
// supplies or sees it) — see cmd/nomos/agent.go's tool dispatch loop.
|
||||||
|
func assentWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID, sessionID string) bool {
|
||||||
|
if agentID == uuid.Nil || sessionID == "" {
|
||||||
|
return false // fail closed: no session to scope to means no window
|
||||||
}
|
}
|
||||||
var expiresStr string
|
var expiresStr string
|
||||||
err := pool.QueryRow(ctx,
|
err := pool.QueryRow(ctx,
|
||||||
"SELECT value FROM autonomy_settings WHERE key = $1",
|
"SELECT value FROM autonomy_settings WHERE key = $1",
|
||||||
"assent_window.agent:"+agentID.String()).Scan(&expiresStr)
|
"assent_window.agent:"+agentID.String()+".session:"+sessionID).Scan(&expiresStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
@@ -1410,20 +1539,21 @@ func assentWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID) b
|
|||||||
}
|
}
|
||||||
|
|
||||||
// destructiveWindowActive reports whether targetSlug has a live, explicitly-
|
// destructiveWindowActive reports whether targetSlug has a live, explicitly-
|
||||||
// confirmed destructive grant for this agent. Key format
|
// confirmed destructive grant for this agent WITHIN THIS SESSION/TASK. Key
|
||||||
// ("destructive_window.agent:<id>.target:<slug>") must match
|
// format ("destructive_window.agent:<id>.target:<slug>.session:<id>") must
|
||||||
// cmd/nomos/store.go's openDestructiveWindow — both processes read/write the
|
// match cmd/nomos/store.go's openDestructiveWindow — both processes
|
||||||
// same autonomy_settings row. Scoped to one target so a typed confirmation
|
// read/write the same autonomy_settings row. Scoped to one target AND one
|
||||||
// for destroying container A can never be read as authorizing anything
|
// session so a typed confirmation for destroying container A in task X can
|
||||||
// against container B.
|
// never be read as authorizing anything against container A from a
|
||||||
func destructiveWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID, targetSlug string) bool {
|
// different, concurrently-running task Y.
|
||||||
if agentID == uuid.Nil || targetSlug == "" {
|
func destructiveWindowActive(ctx context.Context, pool *db.Pool, agentID uuid.UUID, targetSlug, sessionID string) bool {
|
||||||
|
if agentID == uuid.Nil || targetSlug == "" || sessionID == "" {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
var expiresStr string
|
var expiresStr string
|
||||||
err := pool.QueryRow(ctx,
|
err := pool.QueryRow(ctx,
|
||||||
"SELECT value FROM autonomy_settings WHERE key = $1",
|
"SELECT value FROM autonomy_settings WHERE key = $1",
|
||||||
"destructive_window.agent:"+agentID.String()+".target:"+targetSlug).Scan(&expiresStr)
|
"destructive_window.agent:"+agentID.String()+".target:"+targetSlug+".session:"+sessionID).Scan(&expiresStr)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return false
|
return false
|
||||||
}
|
}
|
||||||
|
|||||||
35
internal/safego/safego.go
Normal file
35
internal/safego/safego.go
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
// Package safego provides a goroutine launcher that recovers panics instead
|
||||||
|
// of letting them crash the whole process.
|
||||||
|
//
|
||||||
|
// Go's default behavior for a panic in ANY goroutine — not just the one
|
||||||
|
// serving an HTTP request, which net/http recovers automatically per
|
||||||
|
// request — is to take down the entire process. This codebase runs several
|
||||||
|
// long-lived or unattended background goroutines (the nomos auto-
|
||||||
|
// continuation worker, resumed chat turns, async execution dispatch, the SSE
|
||||||
|
// event listener) that do real work — JSON parsing of model/tool output,
|
||||||
|
// map/slice indexing — with no operator watching. Before this package, a
|
||||||
|
// single edge case in any of them (a malformed tool result, an unexpected
|
||||||
|
// nil) would crash nomos or the api process outright, taking down every
|
||||||
|
// concurrently-running task or request, not just the one that hit it.
|
||||||
|
package safego
|
||||||
|
|
||||||
|
import (
|
||||||
|
"log/slog"
|
||||||
|
"runtime/debug"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Go runs fn in a new goroutine. A panic inside fn is recovered and logged
|
||||||
|
// (with a stack trace) instead of crashing the process. label identifies the
|
||||||
|
// goroutine in logs — use something a reader can trace back to the call
|
||||||
|
// site, e.g. "nomos:continuation-worker" or "mcp:executeApprovedViaAPI".
|
||||||
|
func Go(label string, fn func()) {
|
||||||
|
go func() {
|
||||||
|
defer func() {
|
||||||
|
if r := recover(); r != nil {
|
||||||
|
slog.Error("panic recovered in background goroutine",
|
||||||
|
"goroutine", label, "panic", r, "stack", string(debug.Stack()))
|
||||||
|
}
|
||||||
|
}()
|
||||||
|
fn()
|
||||||
|
}()
|
||||||
|
}
|
||||||
39
internal/safego/safego_test.go
Normal file
39
internal/safego/safego_test.go
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
package safego
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
"testing"
|
||||||
|
)
|
||||||
|
|
||||||
|
// TestGo_RecoversPanic is the concrete proof for the B1 fix in
|
||||||
|
// plans/2026-07-11-nomos-agent-code-review.md: a panic inside a goroutine
|
||||||
|
// launched via Go must not crash the process (or, here, the test binary —
|
||||||
|
// the same guarantee). Before this package existed, every background
|
||||||
|
// goroutine in cmd/nomos/internal/mcp/internal/httpapi used a bare `go`
|
||||||
|
// statement; an unhandled panic in any of them takes down the entire Go
|
||||||
|
// process, not just that goroutine.
|
||||||
|
func TestGo_RecoversPanic(t *testing.T) {
|
||||||
|
var wg sync.WaitGroup
|
||||||
|
wg.Add(1)
|
||||||
|
|
||||||
|
Go("test:deliberate-panic", func() {
|
||||||
|
defer wg.Done()
|
||||||
|
panic("this must be recovered, not crash the test binary")
|
||||||
|
})
|
||||||
|
|
||||||
|
// If the panic weren't recovered, the whole test binary would crash
|
||||||
|
// before ever reaching this line (a Go panic in any goroutine terminates
|
||||||
|
// the process, full stop) — Wait() returning normally IS the proof.
|
||||||
|
wg.Wait()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestGo_RunsFnNormally confirms the non-panic path still just runs fn.
|
||||||
|
func TestGo_RunsFnNormally(t *testing.T) {
|
||||||
|
done := make(chan bool, 1)
|
||||||
|
Go("test:normal", func() {
|
||||||
|
done <- true
|
||||||
|
})
|
||||||
|
if !<-done {
|
||||||
|
t.Fatal("fn did not run")
|
||||||
|
}
|
||||||
|
}
|
||||||
53
migrations/018_tasks.up.sql
Normal file
53
migrations/018_tasks.up.sql
Normal file
@@ -0,0 +1,53 @@
|
|||||||
|
-- 018_tasks.up.sql
|
||||||
|
-- Elevate a chat session into a "task": a goal-structured unit of work with a
|
||||||
|
-- lifecycle status, an outcome, and a one-line summary — the first-class object
|
||||||
|
-- the task board and the live context panel render. See
|
||||||
|
-- plans/2026-07-11-goal-oriented-chat-control-panel.md.
|
||||||
|
--
|
||||||
|
-- entity_id links the session to its OWN entity (type 'task', registered in
|
||||||
|
-- seeds/ontology.yaml) so knowledge notes and involved-entity edges hang off
|
||||||
|
-- the existing relationships graph unchanged — get_relations and
|
||||||
|
-- get_entity_knowledge just work. Intentionally no hard FK (mirrors 017's
|
||||||
|
-- decoupling): a race between task-entity creation and the session insert must
|
||||||
|
-- not be able to break the session.
|
||||||
|
|
||||||
|
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS goal TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS status TEXT NOT NULL DEFAULT 'active';
|
||||||
|
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS outcome TEXT;
|
||||||
|
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS summary TEXT NOT NULL DEFAULT '';
|
||||||
|
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS entity_id UUID;
|
||||||
|
|
||||||
|
-- Ordered plan steps. A step is a described unit of work that maps to a
|
||||||
|
-- run/request_execution call (no fixed step enum, per general-gated-execution).
|
||||||
|
-- execution_id is the gated action a step runs, if any; its terminal status
|
||||||
|
-- auto-closes the step server-side.
|
||||||
|
CREATE TABLE IF NOT EXISTS session_plan_steps (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||||
|
seq INT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
detail TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
-- pending | running | done | failed | skipped | blocked
|
||||||
|
execution_id UUID,
|
||||||
|
target_slug TEXT,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
finished_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_plan_steps_session ON session_plan_steps(session_id, seq);
|
||||||
|
|
||||||
|
-- Structured decisions the agent surfaces to the operator mid-task. context
|
||||||
|
-- carries { entities:[], options:[], why:"" } for the pinned question card.
|
||||||
|
CREATE TABLE IF NOT EXISTS session_questions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
|
context JSONB NOT NULL DEFAULT '{}',
|
||||||
|
status TEXT NOT NULL DEFAULT 'open', -- open | answered | dismissed
|
||||||
|
answer TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
answered_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE INDEX IF NOT EXISTS idx_questions_session_open
|
||||||
|
ON session_questions(session_id) WHERE status = 'open';
|
||||||
10
migrations/019_task_completion_nudges.up.sql
Normal file
10
migrations/019_task_completion_nudges.up.sql
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
-- 019_task_completion_nudges.up.sql
|
||||||
|
-- See plans/2026-07-11-task-completion-safety-net.md (fix 2+3): a
|
||||||
|
-- goal-bearing session (set_goal was called, so it's a real structured
|
||||||
|
-- task, not the trivial-Q&A case handled by the inline safety net) can
|
||||||
|
-- still stall without ever calling complete_task. completion_nudges tracks
|
||||||
|
-- how many times the idle sweep has already nudged a stalled session, so it
|
||||||
|
-- can tell "never nudged" (nudge it) from "nudged once already, still
|
||||||
|
-- stuck" (auto-close it) rather than nudging forever.
|
||||||
|
|
||||||
|
ALTER TABLE agent_sessions ADD COLUMN IF NOT EXISTS completion_nudges INT NOT NULL DEFAULT 0;
|
||||||
@@ -46,6 +46,68 @@ classifier will catch a genuinely dangerous command regardless, but be honest
|
|||||||
about risk in your `purpose` text; the operator is trusting your description
|
about risk in your `purpose` text; the operator is trusting your description
|
||||||
of what a command does.
|
of what a command does.
|
||||||
|
|
||||||
|
## Every chat is a task
|
||||||
|
|
||||||
|
Each conversation is a **task**: a goal the operator wants achieved, from
|
||||||
|
"install service X" to "give me the key status of Y". Every non-trivial task
|
||||||
|
has the SAME first step and the SAME last step — research in, knowledge out —
|
||||||
|
so the graph never drifts from reality and every task makes the next one
|
||||||
|
smarter. Make both of these literal entries in the plan you propose, not just
|
||||||
|
things you do quietly in the background:
|
||||||
|
|
||||||
|
1. **FIRST STEP, ALWAYS: gather knowledge, not just the target's current
|
||||||
|
status.** Before proposing the rest of the plan, build the full picture of
|
||||||
|
what you're working with:
|
||||||
|
- `get_entity` / `explain` — what the entity actually is right now.
|
||||||
|
- `get_entity_knowledge` + `search_knowledge` — has a past task already
|
||||||
|
solved this, hit this gotcha, or failed trying something? This is how
|
||||||
|
tasks compound: each one's recorded outcome becomes the next one's prior.
|
||||||
|
Don't skip it and rediscover a known problem.
|
||||||
|
- `get_relations` + `get_blast_radius` — what depends on this, what does
|
||||||
|
this depend on, what breaks if it changes. Never plan a mutation blind to
|
||||||
|
its neighborhood.
|
||||||
|
- `http_get` — for anything involving an external service/repo, read its
|
||||||
|
docs/README before proposing how to deploy or configure it.
|
||||||
|
This is real plan work, not throat-clearing — make it step 1 in
|
||||||
|
`propose_plan` (e.g. "Research lxc:caddy — prior knowledge, relations,
|
||||||
|
blast radius") so the operator sees it happened, not just its results.
|
||||||
|
2. **Plan, then execute.** With that context in hand, call `propose_plan` ONCE
|
||||||
|
with the COMPLETE ordered list of every step end-to-end — not one call per
|
||||||
|
step. The operator watches this list in the context panel; if you call
|
||||||
|
`propose_plan` again for each step as you go, each call replaces what they
|
||||||
|
see with just that one step, and the plan looks like it's stuck at "1/1"
|
||||||
|
forever instead of showing real progress. Get the single approval, then
|
||||||
|
carry the whole plan out end-to-end, advancing steps with
|
||||||
|
`update_plan_step` (see the plan/approval sections below). If you hit a
|
||||||
|
genuine decision only the operator can make — an ambiguous target, a
|
||||||
|
trade-off, missing information — call `ask_operator` with the options and
|
||||||
|
the entities involved, then STOP and wait; their answer resumes you. Don't
|
||||||
|
ask about things you can settle yourself with tools.
|
||||||
|
3. **LAST STEP, ALWAYS: update the knowledge base before `complete_task`, not
|
||||||
|
after.** Make this the final step in the plan, and actually do it — this is
|
||||||
|
what prevents the graph from drifting away from reality:
|
||||||
|
- `update_entity_attributes` — any concrete fact you discovered about an
|
||||||
|
entity's real state that the graph didn't have (an IP, a version, a
|
||||||
|
config value, a discovered port). Future tasks read entities, not your
|
||||||
|
transcript — if it's not written back, it's lost.
|
||||||
|
- `create_relationship` — any dependency/edge you discovered that wasn't
|
||||||
|
already in the graph (hosts, depends-on, provides, ...).
|
||||||
|
- `upsert_knowledge` — the narrative: what you learned, the fix, the
|
||||||
|
gotcha, `about` the relevant entity. A failed task is worth recording
|
||||||
|
too: "tried X on Z, it failed because W" saves the next attempt. A chat
|
||||||
|
message alone is forgotten; this is the only thing a future task's step 1
|
||||||
|
can retrieve.
|
||||||
|
Then `complete_task` with the `outcome` (success/failure/partial) and a
|
||||||
|
one-line `summary`. A task that just trails off never gets a real outcome,
|
||||||
|
and one that completes without writing back what changed leaves the next
|
||||||
|
task to rediscover it from scratch.
|
||||||
|
|
||||||
|
A trivial read-only task ("what's the status of Y?") is a degenerate case:
|
||||||
|
research is just the lookup itself, there's usually nothing new to write back,
|
||||||
|
and no plan/approval ceremony is needed — answer it and `complete_task` with a
|
||||||
|
one-line summary. Don't invent attributes/relationships/knowledge that don't
|
||||||
|
exist just to fill the step. The loop scales down; it doesn't disappear.
|
||||||
|
|
||||||
## Key MCP tools
|
## Key MCP tools
|
||||||
|
|
||||||
- `list_lxcs` — all LXC containers with host, IP, health (use for fleet-wide questions)
|
- `list_lxcs` — all LXC containers with host, IP, health (use for fleet-wide questions)
|
||||||
@@ -249,9 +311,9 @@ must state the result plainly: what's now true, what you verified, what (if
|
|||||||
anything) failed or remains. Don't end a turn silently or with just a tool
|
anything) failed or remains. Don't end a turn silently or with just a tool
|
||||||
call and no summary — the operator can't see the tools working the way you
|
call and no summary — the operator can't see the tools working the way you
|
||||||
can, and a turn that ends without a status report reads as "nothing happened."
|
can, and a turn that ends without a status report reads as "nothing happened."
|
||||||
When the whole goal is done and verified, say so explicitly and — if you
|
When the whole goal is done and verified, say so explicitly, `upsert_knowledge`
|
||||||
learned anything non-obvious getting there — `upsert_knowledge` it before you
|
anything non-obvious you learned, and call `complete_task` with the outcome and
|
||||||
sign off.
|
a one-line summary so the task board reflects the real result.
|
||||||
|
|
||||||
## Skills
|
## Skills
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,10 @@
|
|||||||
# 2026-07-08 — Control room web UI
|
# 2026-07-08 — Control room web UI
|
||||||
|
|
||||||
**Status:** In Progress — N0-N3 (Nomos amendment: chat home + sessions), M1
|
**Status:** In Progress (audited 2026-07-11 — still accurate; remaining gaps:
|
||||||
|
`signal.acked`/`signal.resolved`/`signal.muted` and `relationship.created`/
|
||||||
|
`relationship.ended` API calls don't emit `observability.Event`, and
|
||||||
|
trusted-proxy header auth for Authentik was never added to `combinedAuth`).
|
||||||
|
N0-N3 (Nomos amendment: chat home + sessions), M1
|
||||||
(dashboard/summary, Overview, Entities table, live event feed, shadcn-svelte
|
(dashboard/summary, Overview, Entities table, live event feed, shadcn-svelte
|
||||||
component system), M2 (Operations ledger with approve/deny + cancel, Signals
|
component system), M2 (Operations ledger with approve/deny + cancel, Signals
|
||||||
page with ack/resolve/mute, live nav badges), and M3 (graph explorer with
|
page with ack/resolve/mute, live nav badges), and M3 (graph explorer with
|
||||||
|
|||||||
@@ -1,6 +1,8 @@
|
|||||||
# 2026-07-08 — Liveness, drift, and UX cohesion
|
# 2026-07-08 — Liveness, drift, and UX cohesion
|
||||||
|
|
||||||
**Status:** In Progress — Phases 1–4 code complete; not yet deployed. Phase 5 deferred.
|
**Status:** In Progress — Phases 1–4 code complete; not yet deployed. Phase 5 deferred.
|
||||||
|
(Audited 2026-07-11 — still accurate; prompt caching within Phase 4 also
|
||||||
|
confirmed not implemented.)
|
||||||
|
|
||||||
- **Phase 1 (drift/staleness):** done. Health/metrics/events misattribution
|
- **Phase 1 (drift/staleness):** done. Health/metrics/events misattribution
|
||||||
fix, staleness sweep, `/entities` health+freshness, dashboard/fleet-health
|
fix, staleness sweep, `/entities` health+freshness, dashboard/fleet-health
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
# 2026-07-08 — Oikos gaps, broken things, and improvements
|
# 2026-07-08 — Oikos gaps, broken things, and improvements
|
||||||
|
|
||||||
**Status:** Planned
|
**Status:** In Progress — audited 2026-07-11. Done: A1 (approval FK bug),
|
||||||
|
A3 (Hermes→Nomos help text), D1 (`upsert_knowledge`), D4-partial (general
|
||||||
|
`run` tool). Still open: A2 (notifier flooding/dedup), A4 (`resolveHost`
|
||||||
|
dead code), A5 (`queryRows` stringly-typed columns), A6 (stale
|
||||||
|
`get_state_snapshot` description), B1-B5 (enrollment auth, fake Infisical
|
||||||
|
creds, `/query` mesh-only auth unenforced, insecure host key checking,
|
||||||
|
optional `caller_pubkey`), D2/D3 (no `get_approval_status`/
|
||||||
|
`list_pending_approvals`/signal ack-resolve-mute tools), E (README tool
|
||||||
|
count, Caddyfile placeholders, NOMOS.md duplicate line).
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,13 @@
|
|||||||
# 2026-07-10 — General gated execution: from fixed actions to unlimited-but-gated
|
# 2026-07-10 — General gated execution: from fixed actions to unlimited-but-gated
|
||||||
|
|
||||||
**Status:** Planned
|
**Status:** In Progress — audited 2026-07-11. Done: `ClassifyCommand` risk
|
||||||
|
classifier, general `run` MCP tool, chat-assent approval (no button
|
||||||
|
required), blast radius on approval cards, session digest, global activity
|
||||||
|
feed (`Ops.svelte` "Executions" tab, risk-badged), Learning view
|
||||||
|
(success-rate trend). Still open: retire the fixed `request_execution`
|
||||||
|
action enum (`restart, systemctl, pct_exec, apt_upgrade, pct_create` still
|
||||||
|
hard-coded alongside `run`), and revive auto-act — `internal/actuator/actuator.go:125`
|
||||||
|
is still a literal `{"success": true, "message": "stub execution"}` stub.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
|
|||||||
336
plans/2026-07-11-nomos-agent-code-review.md
Normal file
336
plans/2026-07-11-nomos-agent-code-review.md
Normal file
@@ -0,0 +1,336 @@
|
|||||||
|
# 2026-07-11 — Nomos agent code review: gaps and improvement plan
|
||||||
|
|
||||||
|
**Status:** In Progress — 2026-07-11. Every finding except C1 (A1-A3, B1-B3,
|
||||||
|
D1-D3, E, F1) is fixed, tested, and verified live against the running stack.
|
||||||
|
C1 (unauthenticated nomos gateway) is explicitly deferred per operator
|
||||||
|
instruction ("leave auth out for these round of fixes") — the one item
|
||||||
|
keeping this out of `done/`.
|
||||||
|
|
||||||
|
- A1 `3919ec3`, B1+B2 `c5ffaec`, A3 `926969a`, D1-D3 `76f7630`,
|
||||||
|
A2 `c390164`, B3 `6d4f6de`, F1 `11c18e8`.
|
||||||
|
- New `internal/safego` package (B1) and `cmd/nomos/store_test.go` (A2, plus
|
||||||
|
a regression test for the earlier plan-append fix) are the first automated
|
||||||
|
tests for any of this package's core logic — closing part of finding E,
|
||||||
|
though full coverage of agent.go/main.go remains future work.
|
||||||
|
- C1 remains open — nomos's gateway (port 8092) still has no authentication.
|
||||||
|
Revisit separately.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
A full read-through of `cmd/nomos/` (agent.go, store.go, main.go, continue.go,
|
||||||
|
assent.go, tasks.go — 3,120 lines) plus targeted checks of its HTTP exposure,
|
||||||
|
goroutine safety, and test coverage. Every finding below is grounded in a
|
||||||
|
specific file:line or a runnable reproduction — two of the sharper ones
|
||||||
|
(A1, A2) were empirically confirmed with throwaway test probes before being
|
||||||
|
written up, not just read and assumed.
|
||||||
|
|
||||||
|
This is a review, not an implementation — findings are ranked by severity with
|
||||||
|
a proposed fix per item; nothing here has been changed yet.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## A. Correctness bugs (confirmed, not theoretical)
|
||||||
|
|
||||||
|
### A1. Chat-assent word matching has real substring false positives
|
||||||
|
|
||||||
|
[assent.go:73-103](../cmd/nomos/assent.go). `isAssent`/`isTypedConfirmation`
|
||||||
|
pad the message with spaces and word-boundary-check the **negation** list
|
||||||
|
(`strings.Contains(m, " "+w+" ")`), but the **assent**/**confirm** checks use
|
||||||
|
bare `strings.Contains(m, w)` — no word boundary at all. Confirmed live via a
|
||||||
|
test probe:
|
||||||
|
|
||||||
|
- `isAssent("not sure, maybe yesterday's logs show something useful")` →
|
||||||
|
**`true`** (`"yes"` matches inside `"yesterday"`; `"not"` alone isn't in
|
||||||
|
`negationWords`, only the phrase `"not yet"` is).
|
||||||
|
- `isTypedConfirmation("I haven't confirmed anything yet, let me think")` →
|
||||||
|
**`true`** (`"confirm"` matches inside `"confirmed"`; `"haven't"` isn't in
|
||||||
|
`negationWords`, which only has `"don't"`/`"do not"`, not other contracted
|
||||||
|
negatives).
|
||||||
|
|
||||||
|
The second one is the serious half: `isTypedConfirmation` is the **sole gate
|
||||||
|
for DESTRUCTIVE actions** ([agent.go:220-223](../cmd/nomos/agent.go)) — a
|
||||||
|
message that merely *mentions* not having confirmed something yet can read as
|
||||||
|
an explicit confirmation.
|
||||||
|
|
||||||
|
**Fix:** apply the same space-padded word-boundary check to the assent/confirm
|
||||||
|
word lists that negation already uses. Expand `negationWords` to cover
|
||||||
|
contracted negatives (`haven't`, `hasn't`, `isn't`, `wasn't`, `can't`,
|
||||||
|
`won't`, `not` as a standalone word, not just `"not yet"`). Add both
|
||||||
|
reproduced cases as permanent regression tests in `assent_test.go`.
|
||||||
|
|
||||||
|
### A2. Unbounded conversation history replay — no windowing, no token budget
|
||||||
|
|
||||||
|
[agent.go:185-207](../cmd/nomos/agent.go): every single turn (`chatWith`)
|
||||||
|
calls `a.store.getMessages(ctx, sessionID)` — [store.go:218-239](../cmd/nomos/store.go),
|
||||||
|
`SELECT ... WHERE session_id=$1 ORDER BY created_at ASC` with **no `LIMIT`,
|
||||||
|
no windowing, no summarization** — and replays the *entire* history into the
|
||||||
|
LLM call every time. `truncateToolResults` ([store.go:152-185](../cmd/nomos/store.go))
|
||||||
|
caps each individual tool **result** at 4KB, but caps nothing else: not tool
|
||||||
|
**args**, not the number of tool calls in one message, not the total message
|
||||||
|
count, not total tokens.
|
||||||
|
|
||||||
|
This isn't theoretical — an earlier production audit (see
|
||||||
|
[chat-sessions-improvements](done/2026-07-09-chat-sessions-improvements.md))
|
||||||
|
found a single turn with **70 tool calls** and messages up to **106KB**. Every
|
||||||
|
subsequent turn of a long-running or heavily-autonomous task (exactly what
|
||||||
|
auto-continuation is built for) re-sends that ever-growing history in full.
|
||||||
|
This is a real cost, latency, and eventual context-length-limit risk that
|
||||||
|
compounds specifically for the tasks the system is designed to run longest.
|
||||||
|
|
||||||
|
**Fix:** at minimum, cap replayed history to the most recent N messages or a
|
||||||
|
token budget, with older turns either dropped or collapsed into a short
|
||||||
|
system-message summary (`finalSummary`'s existing one-shot summarization
|
||||||
|
pattern, [agent.go:481-492](../cmd/nomos/agent.go), could be reused for this).
|
||||||
|
Needs a decision on where the cutoff lives (see open questions).
|
||||||
|
|
||||||
|
### A3. A live turn's tool-call history is lost entirely if the client disconnects mid-stream
|
||||||
|
|
||||||
|
[main.go handleChat](../cmd/nomos/main.go): `toolCalls`/`finalText` accumulate
|
||||||
|
only in local closure variables; `st.saveMessage(...)` runs exactly **once**,
|
||||||
|
after `a.chat(...)` returns, using `ctx := r.Context()` — the *same* context
|
||||||
|
that cancels the instant the client disconnects (Stop button, tab close,
|
||||||
|
network blip). If `a.chat` returns early because that context was cancelled,
|
||||||
|
the final `saveMessage` call runs with an already-cancelled context and its
|
||||||
|
error return is never checked — the whole turn's tool-call history (already
|
||||||
|
real: executions launched, knowledge possibly written) is silently lost from
|
||||||
|
the persisted transcript.
|
||||||
|
|
||||||
|
Contrast with `resumeSession`/`continueSession` ([continue.go:96-166](../cmd/nomos/continue.go)),
|
||||||
|
which insert a placeholder row immediately and update it after every single
|
||||||
|
tool call — exactly the incremental-persistence pattern `handleChat` lacks.
|
||||||
|
Verified live this session: my own Stop-button test showed the turn's actual
|
||||||
|
tool calls (6 of them) *were* visible in the UI only because the SSE stream
|
||||||
|
had already pushed them to the browser's in-memory store before the abort —
|
||||||
|
none of that would have survived a page reload, since nothing was persisted.
|
||||||
|
|
||||||
|
**Fix:** bring `handleChat` in line with `resumeSession`'s pattern — insert a
|
||||||
|
placeholder row before the turn starts, update it after each tool call using
|
||||||
|
a context *not* tied to the client connection for the write itself (or at
|
||||||
|
minimum, persist with `context.Background()` in a deferred cleanup so a
|
||||||
|
cancelled request context doesn't take the DB write down with it).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## B. Robustness
|
||||||
|
|
||||||
|
### B1. Zero panic recovery on any background goroutine
|
||||||
|
|
||||||
|
Every explicitly-spawned goroutine across the agent surface has no
|
||||||
|
`recover()`:
|
||||||
|
|
||||||
|
```
|
||||||
|
cmd/nomos/main.go:78 go nAgent.runContinuationWorker(ctx)
|
||||||
|
cmd/nomos/main.go:80 go func() { ...sweep ticker... }()
|
||||||
|
cmd/nomos/main.go:117 go func() { ...http server... }()
|
||||||
|
cmd/nomos/main.go:347 go a.resumeSession(context.Background(), sessionID, note)
|
||||||
|
internal/mcp/server.go:477,495 go executeApprovedViaAPI(...)
|
||||||
|
internal/mcp/server.go:1134 go func() { ... }()
|
||||||
|
internal/httpapi/phase3.go:119,1456
|
||||||
|
internal/httpapi/server.go:81,533
|
||||||
|
```
|
||||||
|
|
||||||
|
`grep -rn "recover()" cmd/nomos/ internal/mcp/ internal/httpapi/` returns
|
||||||
|
nothing. Go's default behavior for a panic in *any* goroutine — not just the
|
||||||
|
one handling an HTTP request, which the stdlib does recover — is to crash the
|
||||||
|
**entire process**. `runContinuationWorker` and `resumeSession` in particular
|
||||||
|
run complex, unattended agent logic (JSON unmarshaling of model output, tool
|
||||||
|
result parsing, map/slice indexing) with no operator watching; a single edge
|
||||||
|
case (a malformed tool result, an unexpected nil) takes down nomos for
|
||||||
|
**every concurrently-running task**, not just the one that hit it. This is
|
||||||
|
more consequential post-concurrency (today's work): more simultaneous
|
||||||
|
unattended goroutines running agent code means more surface area for one bad
|
||||||
|
input to end everyone's session.
|
||||||
|
|
||||||
|
**Fix:** wrap every explicitly-spawned goroutine body in a `defer func() {
|
||||||
|
if r := recover(); r != nil { slog.Error(...) } }()`. A small helper
|
||||||
|
(`safeGo(func())`) would make this consistent and hard to forget at new call
|
||||||
|
sites.
|
||||||
|
|
||||||
|
### B2. Auto-continuation processes its batch sequentially, one full turn at a time
|
||||||
|
|
||||||
|
[continue.go:58-75](../cmd/nomos/continue.go): `processContinuations` fetches
|
||||||
|
up to 5 pending items and runs `a.continueSession(ctx, p)` for each **in a
|
||||||
|
plain `for` loop**, in the single `runContinuationWorker` goroutine. Each
|
||||||
|
`continueSession` is a full LLM turn that can run for minutes (10-minute
|
||||||
|
timeout, [continue.go:134](../cmd/nomos/continue.go)). If 3 different tasks'
|
||||||
|
executions finish in the same 4-second tick, task #3's continuation waits for
|
||||||
|
#1 and #2 to *completely finish* first — undercutting today's whole
|
||||||
|
concurrency effort specifically on the auto-continuation path, which is the
|
||||||
|
mechanism autonomous multi-step tasks depend on most.
|
||||||
|
|
||||||
|
**Fix:** spawn each pending continuation as its own goroutine (with B1's
|
||||||
|
panic recovery), bounded by a small semaphore if unbounded parallelism here
|
||||||
|
is a concern.
|
||||||
|
|
||||||
|
### B3. No terminal state for a permanently-failed auto-continuation
|
||||||
|
|
||||||
|
[continue.go:162-165](../cmd/nomos/continue.go): if the resumed LLM call
|
||||||
|
errors on both the initial attempt and its one retry, the code logs an error
|
||||||
|
and returns — the task is left in whatever status it was in (typically
|
||||||
|
`executing`), with no outcome set and no operator-visible signal beyond an
|
||||||
|
inert message buried in the transcript. There's no give-up-after-N-retries or
|
||||||
|
dead-letter marking; the task just looks silently stuck.
|
||||||
|
|
||||||
|
**Fix:** on final failure, call the same path `complete_task` would use to set
|
||||||
|
`outcome='failure'` with a summary explaining the resume failed, so the task
|
||||||
|
board reflects reality instead of showing a task that looks perpetually
|
||||||
|
"executing."
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## C. Security
|
||||||
|
|
||||||
|
### C1. Nomos's own HTTP gateway has zero authentication
|
||||||
|
|
||||||
|
[docker-compose.yml:133](../docker-compose.yml) publishes port 8092 directly
|
||||||
|
(`"8092:8092"`, comment: *"mesh-published"*) and
|
||||||
|
[Caddyfile.oikos](../compose/caddy/Caddyfile.oikos:19,34) reverse-proxies to
|
||||||
|
it from two routes. `grep -n "Authorization\|Bearer\|auth" cmd/nomos/main.go`
|
||||||
|
returns **nothing** — `/chat`, `/sessions`, `/sessions/{id}` (including
|
||||||
|
`DELETE`), and `/query` have no credential check of any kind. Anyone who can
|
||||||
|
reach the LAN or mesh network can converse with Nomos directly: start tasks,
|
||||||
|
read/delete any session, answer pending questions, and — via chat-assent —
|
||||||
|
approve gated executions by typing "yes" or "I confirm" to whatever the agent
|
||||||
|
proposes, with no authentication at all. This is the same class of gap
|
||||||
|
[oikos-gaps-and-improvements](2026-07-08-oikos-gaps-and-improvements.md)
|
||||||
|
flagged for the `api`/MCP surface (items B1-B5), but specifically for nomos's
|
||||||
|
*own* port, which doesn't sit behind `combinedAuth` the way `api`'s routes do.
|
||||||
|
|
||||||
|
**Fix:** put nomos's gateway behind the same auth the `api` process uses
|
||||||
|
(shared bearer token check at minimum), or stop publishing 8092 directly and
|
||||||
|
route all traffic through the already-authenticated `api` proxy exclusively.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## D. Code quality
|
||||||
|
|
||||||
|
### D1. Dead code: `isTaskTool` is defined, never called
|
||||||
|
|
||||||
|
[tasks.go:139-146](../cmd/nomos/tasks.go). The actual dispatch in
|
||||||
|
[agent.go:370](../cmd/nomos/agent.go) calls `a.handleTaskTool(...)` directly
|
||||||
|
and checks its `handled` return value — `isTaskTool` is unused.
|
||||||
|
**Fix:** delete it, or use it in `buildTools`/dispatch if a cheaper
|
||||||
|
pre-check is actually wanted.
|
||||||
|
|
||||||
|
### D2. N+1 query in `recordTouched`
|
||||||
|
|
||||||
|
[store.go:720-742](../cmd/nomos/store.go): loops over every slug found in a
|
||||||
|
tool call's args and issues a separate `SELECT id, type FROM entities WHERE
|
||||||
|
slug = $1` per slug. Fine for the common case (1-3 slugs) but doesn't batch
|
||||||
|
for tool calls naming many entities.
|
||||||
|
**Fix:** one `SELECT id, slug, type FROM entities WHERE slug = ANY($1)` for
|
||||||
|
all collected slugs, then loop over the results in memory.
|
||||||
|
|
||||||
|
### D3. `complete_task`'s outcome isn't validated
|
||||||
|
|
||||||
|
[tasks.go:248-257](../cmd/nomos/tasks.go) declares an `enum` in the tool
|
||||||
|
schema (`success|failure|partial`) but [store.go:428-457](../cmd/nomos/store.go)
|
||||||
|
never checks it — an out-of-enum value (a model typo, or a weaker model not
|
||||||
|
respecting the schema) silently persists as-is; only `"failure"` is
|
||||||
|
special-cased (else `status="done"`), so a stray value still "completes" the
|
||||||
|
task but with a value the frontend's status/outcome rendering doesn't
|
||||||
|
recognize.
|
||||||
|
**Fix:** validate against the three allowed values in `handleTaskTool` before
|
||||||
|
calling `store.completeTask`, defaulting unrecognized values to `"partial"`
|
||||||
|
(safer than silently treating them as `"success"`).
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## E. Test coverage
|
||||||
|
|
||||||
|
**Zero automated tests exist for `agent.go`, `store.go`, `main.go`, or
|
||||||
|
`tasks.go`.** Only `assent.go`'s and `continue.go`'s pure string-parsing
|
||||||
|
helpers have unit tests (`assent_test.go`, `continue_test.go`) — confirmed by
|
||||||
|
`grep -l "func Test" cmd/nomos/*.go` matching only those two files. This means
|
||||||
|
today's session added substantial new, safety-critical logic — session-scoped
|
||||||
|
assent/destructive windows, the `mcpClientPool`'s creation-race handling and
|
||||||
|
eviction sweep, `proposePlan`'s replace-vs-append branching — verified only by
|
||||||
|
live manual testing (curl + browser), with **no regression protection**
|
||||||
|
against a future change silently reintroducing the cross-task assent bleed or
|
||||||
|
breaking the pool's session isolation.
|
||||||
|
|
||||||
|
**Fix (highest-value additions first):**
|
||||||
|
1. `store_test.go`: `proposePlan`'s append-vs-replace branch (the exact bug
|
||||||
|
fixed earlier today) — needs a real DB (integration-style, matching
|
||||||
|
`internal/db/integration_test.go`'s pattern) or a query-mocking layer.
|
||||||
|
2. `main_test.go`: `mcpClientPool.get()`'s concurrent-creation race path (two
|
||||||
|
goroutines racing to create a client for the same new session id) and
|
||||||
|
`sweep()`'s eviction logic — these are pure in-memory logic, no DB needed,
|
||||||
|
straightforward to unit test.
|
||||||
|
3. `assent_test.go`: the two confirmed false-positive cases from A1.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## F. Efficiency (minor)
|
||||||
|
|
||||||
|
### F1. Tool list + fleet snapshot re-fetched every single turn
|
||||||
|
|
||||||
|
[agent.go:174,181](../cmd/nomos/agent.go): `buildTools` (`tools/list` MCP
|
||||||
|
round-trip) and `fleetSnapshot` (`get_health_summary` call) both run at the
|
||||||
|
start of **every** `chatWith` call — including auto-continuation resumes,
|
||||||
|
which can fire many times per task. The tool list changes only on an `api`
|
||||||
|
process restart; the fleet snapshot is a live "as of now" read, which is
|
||||||
|
arguably the point of it, but re-fetching the *tool list* every turn is
|
||||||
|
avoidable.
|
||||||
|
**Fix:** cache `buildTools`' result (e.g., in `mcpClientPool`, invalidated on
|
||||||
|
a client's re-initialize) — worth doing only if profiling shows it matters;
|
||||||
|
low priority relative to A-C.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Implementation order
|
||||||
|
|
||||||
|
1. **A1** (assent false positives) — smallest, highest-severity-per-line-of-
|
||||||
|
code fix; ships with regression tests same-PR.
|
||||||
|
2. **C1** (unauthenticated gateway) — security-critical, independent of
|
||||||
|
everything else here.
|
||||||
|
3. **B1** (panic recovery) — cheap, broad safety net; do before B2 touches the
|
||||||
|
continuation worker's goroutine structure anyway.
|
||||||
|
4. **B2** (parallel auto-continuation) — natural follow-on to B1 since it's
|
||||||
|
restructuring the same goroutine.
|
||||||
|
5. **A3** (incremental persistence for live turns) — moderate effort, real
|
||||||
|
user-visible correctness gain.
|
||||||
|
6. **D1-D3** (small cleanups) — bundle together, low risk.
|
||||||
|
7. **A2** (history windowing) — needs a design decision (see below) before
|
||||||
|
implementation; largest single change.
|
||||||
|
8. **B3**, **F1** — lower urgency, do opportunistically.
|
||||||
|
9. **E** (tests) — ideally lands alongside each fix above (A1's tests with
|
||||||
|
A1, etc.) rather than as one giant deferred test-writing pass.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- **A1**: the two probe cases (`isAssent` on the "yesterday" message,
|
||||||
|
`isTypedConfirmation` on the "haven't confirmed" message) become permanent
|
||||||
|
tests in `assent_test.go`, asserting `false` post-fix.
|
||||||
|
- **A2**: after adding windowing, replay a session with 70+ tool calls (the
|
||||||
|
documented production case) and confirm the message payload sent to the LLM
|
||||||
|
stays under a fixed token/byte ceiling regardless of session length.
|
||||||
|
- **A3**: reproduce the Stop-button-mid-turn scenario, reload the page, and
|
||||||
|
confirm the tool calls made before the abort are still present in the
|
||||||
|
persisted transcript (currently: they vanish).
|
||||||
|
- **B1**: inject a deliberate panic in a test build of `resumeSession` (or a
|
||||||
|
fault-injection flag), confirm the process survives and logs the recovered
|
||||||
|
panic instead of exiting.
|
||||||
|
- **C1**: confirm an unauthenticated `curl` to nomos's `/chat` from off-mesh
|
||||||
|
is rejected once auth lands (currently: succeeds).
|
||||||
|
- **D1-D3**: `go vet`/build clean, `complete_task` with a bogus outcome value
|
||||||
|
now rejected or defaulted rather than silently persisted.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **A2's cutoff mechanism**: a fixed N-message window, a token-budget-aware
|
||||||
|
trim, or LLM-summarization of dropped history? Summarization preserves the
|
||||||
|
most context but costs an extra LLM call per trim; a fixed window is
|
||||||
|
simplest but could drop something the agent still needs mid-task. Leaning
|
||||||
|
fixed window + summarize-on-trim as a middle ground, but this needs a
|
||||||
|
decision before implementation, not during.
|
||||||
|
- **C1's auth mechanism**: reuse `api`'s existing static bearer token
|
||||||
|
(simplest, matches an existing pattern) or route everything through `api`'s
|
||||||
|
proxy and stop publishing 8092 at all (removes the surface entirely, but
|
||||||
|
changes the deploy topology)? Leaning the latter if nothing else on the LAN
|
||||||
|
legitimately needs to reach nomos directly — worth confirming with the
|
||||||
|
operator before picking.
|
||||||
|
- **B2's concurrency bound**: unbounded goroutines-per-tick vs. a small
|
||||||
|
semaphore? Given the continuation batch is already capped at 5 per tick
|
||||||
|
(`pendingContinuations(ctx, 5)`), unbounded is probably fine, but worth a
|
||||||
|
sanity check against real task-completion clustering patterns.
|
||||||
@@ -1,6 +1,8 @@
|
|||||||
# 2026-07-08 — Nomos resident agent (renames Hermes)
|
# 2026-07-08 — Nomos resident agent (renames Hermes)
|
||||||
|
|
||||||
**Status:** In Progress — N0-N3 complete 2026-07-08
|
**Status:** Done — 2026-07-11. N0-N3 (rename, agent loop, sessions/streaming,
|
||||||
|
UI entry point) all verified in current code. N4 (Matrix bridge, proactive
|
||||||
|
sessions) was explicitly out of scope and remains unstarted.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
@@ -1,6 +1,12 @@
|
|||||||
# 2026-07-08 — Plan vs implementation cross-reference
|
# 2026-07-08 — Plan vs implementation cross-reference
|
||||||
|
|
||||||
**Status:** Planned
|
**Status:** Done — 2026-07-11. Every action this audit recommended has a
|
||||||
|
corresponding follow-up commit (consolidation `7660e56`, client lifecycle
|
||||||
|
`efa66c7`/`fcd9f23`/`28ab9b8`, comprehensive audit `43aaf2a`,
|
||||||
|
DB-as-source-of-truth `a3ebd12`, MCP tool surface `7c6cffb`, apps/105 webhook
|
||||||
|
cleanup `cefeba7`). Its own Prometheus finding (0% done) still matches the
|
||||||
|
current state — see [2026-07-05-oikos-prometheus-lxc.md](2026-07-05-oikos-prometheus-lxc.md),
|
||||||
|
still Planned.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
# 2026-07-09 — Chat sessions: reliability, cost, and session-management fixes
|
# 2026-07-09 — Chat sessions: reliability, cost, and session-management fixes
|
||||||
|
|
||||||
**Status:** Planned
|
**Status:** Done — 2026-07-11. All 5 findings fixed on `main`
|
||||||
|
(`49c37fe fix: chat session reliability, cost, and hygiene`): empty/refusal
|
||||||
|
retry guard in `agent.go`, bulk-tool guidance in `SOUL.md`, tool-result
|
||||||
|
truncation in `store.go`, `get_state_snapshot` filtering, and session
|
||||||
|
delete + generated titles.
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
@@ -1,6 +1,11 @@
|
|||||||
# 2026-07-09 — Session execution, UX, and learning improvements
|
# 2026-07-09 — Session execution, UX, and learning improvements
|
||||||
|
|
||||||
**Status:** Planned
|
**Status:** Done — 2026-07-11. Hard blocker and all major items verified:
|
||||||
|
`pct_create` wired into `request_execution`, `ToolCallGroup` collapse +
|
||||||
|
live status, `InlineApproval` blast radius, `http_get` tool, `session-review`
|
||||||
|
skill. Two minor secondary items not implemented: `list_lxcs` CPU/mem
|
||||||
|
enrichment, and a dedicated `get_tools_summary` tool (SOUL.md has general
|
||||||
|
bulk-tool guidance instead).
|
||||||
|
|
||||||
## Goal
|
## Goal
|
||||||
|
|
||||||
@@ -1,6 +1,10 @@
|
|||||||
# 2026-07-10 — Autonomous plan execution: close the observation gap
|
# 2026-07-10 — Autonomous plan execution: close the observation gap
|
||||||
|
|
||||||
**Status:** Planned
|
**Status:** Done — 2026-07-11. Full scope shipped, including the Option B
|
||||||
|
stretch goal: atomic `pct_create` decomposition, robust assent-window
|
||||||
|
open/extend, SOUL persist-through-errors + `maxIterations=40`, and
|
||||||
|
event-driven auto-continuation (`cmd/nomos/continue.go`). Commits `233b5e4`,
|
||||||
|
`d2f749d`, `657e1a8`, `d529688`, `84ecb6b`.
|
||||||
|
|
||||||
## The real problem (not the one we kept fixing)
|
## The real problem (not the one we kept fixing)
|
||||||
|
|
||||||
235
plans/done/2026-07-11-concurrent-task-execution.md
Normal file
235
plans/done/2026-07-11-concurrent-task-execution.md
Normal file
@@ -0,0 +1,235 @@
|
|||||||
|
# 2026-07-11 — Concurrent task execution: safety + throughput + frontend correctness
|
||||||
|
|
||||||
|
**Status:** Done — 2026-07-11. All three required fixes shipped and deployed:
|
||||||
|
session-scoped assent/destructive windows (commit `9ef1ba3`), the frontend
|
||||||
|
stream-corruption guard + per-session controllers (`9131559`, `6a8fb43`), and
|
||||||
|
the per-session MCP client pool (`a4ea542`). Fix 4 (concurrency/cost cap)
|
||||||
|
remains explicitly deferred pending real usage data, per this doc's own
|
||||||
|
recommendation.
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Multiple tasks already run "at the same time" at the HTTP/goroutine level —
|
||||||
|
nothing in nomos serializes whole turns. But tracing the actual code (not
|
||||||
|
assuming) surfaces three real gaps that make concurrent tasks unsafe or
|
||||||
|
broken today, in decreasing severity: a **cross-task authorization bleed**, a
|
||||||
|
**throughput bottleneck** that makes concurrency mostly illusory, and a
|
||||||
|
**frontend state-corruption bug**. This plan fixes all three.
|
||||||
|
|
||||||
|
## Findings (grounded)
|
||||||
|
|
||||||
|
### 1. CRITICAL — the assent window is scoped to the agent, not the task
|
||||||
|
|
||||||
|
[store.go:816](../../cmd/nomos/store.go), [agent.go:129-143](../../cmd/nomos/agent.go):
|
||||||
|
`openAssentWindow`/`assentWindowActive` key on `"assent_window.agent:" +
|
||||||
|
a.agentID.String()` — there is exactly one `agent:nomos` entity, so this key
|
||||||
|
is **global across every task**. It's checked at three MCP call sites
|
||||||
|
([internal/mcp/server.go:454](../../internal/mcp/server.go), :488, :1363) purely
|
||||||
|
as "does *the agent* currently have an open window," with no way to know
|
||||||
|
which task's tool call is asking.
|
||||||
|
|
||||||
|
**Concrete failure**: operator approves Task A's plan → 30-minute window
|
||||||
|
opens → operator starts Task B while that window is still open → Task B's
|
||||||
|
`run`/`request_execution` config-mutation calls **also auto-execute**,
|
||||||
|
because the check has no session dimension. The operator never approved
|
||||||
|
Task B's plan.
|
||||||
|
|
||||||
|
[store.go:313-345](../../cmd/nomos/store.go) `destructiveWindowKey`/
|
||||||
|
`openDestructiveWindow`/`destructiveWindowActive` have the same shape (keyed
|
||||||
|
`agent:<id>.target:<slug>`, no session) — narrower blast radius (needs a
|
||||||
|
second task hitting the *same target* within 15 minutes of an explicit typed
|
||||||
|
confirmation elsewhere) but the same class of bug.
|
||||||
|
|
||||||
|
### 2. Tool calls across ALL tasks funnel through one mutex — concurrency is mostly illusory
|
||||||
|
|
||||||
|
[main.go:45](../../cmd/nomos/main.go): nomos creates exactly **one**
|
||||||
|
`*mcpClient` at startup, shared by every `handleChat` goroutine. Its `mu
|
||||||
|
sync.Mutex` ([main.go:419](../../cmd/nomos/main.go)) is held for the full
|
||||||
|
duration of each `doRequest` round-trip. `run`'s MCP handler executes the SSH
|
||||||
|
command *synchronously inside that round-trip* and is capped at up to **10
|
||||||
|
minutes**. So while Task A is mid-`run`, every other task's tool calls —
|
||||||
|
even a trivial `get_entity` — queue behind that single mutex until it
|
||||||
|
returns. Tasks can think (LLM calls) in parallel, but cannot act in parallel;
|
||||||
|
one slow task stalls all others' progress.
|
||||||
|
|
||||||
|
The MCP *server* side has no session-scoped in-memory state to protect —
|
||||||
|
`newServer(pool, agentID)` returns one shared `*mcp.Server` instance whose
|
||||||
|
tool handlers close only over `pool` (safe for concurrent use — pgxpool is a
|
||||||
|
connection pool) and `agentID` ([internal/mcp/server.go:51-74](../../internal/mcp/server.go)).
|
||||||
|
The mutex exists purely because nomos's *client* reuses one stateful
|
||||||
|
transport session, not because the server needs it. This is fixable without
|
||||||
|
touching the server.
|
||||||
|
|
||||||
|
### 3. Frontend: the chat store is a global singleton — switching tasks mid-stream corrupts the view
|
||||||
|
|
||||||
|
[chat.ts:160-269](../../web/src/lib/stores/chat.ts) `sendMessage`'s SSE callback
|
||||||
|
mutates `messages`/`currentSession` by reaching for `ms[ms.length - 1]` —
|
||||||
|
i.e. it assumes the array it's mutating still belongs to the task it was
|
||||||
|
opened for. Nothing in the callback checks that. [chat.ts:111-117](../../web/src/lib/stores/chat.ts)
|
||||||
|
`loadSessionMessages` (fired when you click a different task in the sidebar
|
||||||
|
or the board) does not cancel or otherwise account for a still-open stream
|
||||||
|
from the task you're leaving — it just calls `messages.set(...)` and
|
||||||
|
`currentSession.set(sessionId)`.
|
||||||
|
|
||||||
|
**Concrete failure**: start Task A, while it's still streaming click into
|
||||||
|
Task B from the Tasks board → `messages`/`currentSession` now reflect Task
|
||||||
|
B → Task A's still-open SSE stream delivers its next `tool_use`/`text_delta`
|
||||||
|
→ the callback appends it onto what is now *Task B's* last message, and on
|
||||||
|
`done` calls `currentSession.set(taskAId)`, flipping the app back to Task A
|
||||||
|
underneath the operator. This is a real bug independent of anything else in
|
||||||
|
this plan — it's why "switch away from a running task to start another"
|
||||||
|
currently looks broken even though the backend handles it fine.
|
||||||
|
|
||||||
|
(By contrast, [workspace.ts](../../web/src/lib/stores/workspace.ts)'s live
|
||||||
|
events are already correctly session-scoped — `applyEvent` checks
|
||||||
|
`ev.correlation_id !== sid` before doing anything — because that mechanism
|
||||||
|
was built for this from phase 6. The bug is confined to the older,
|
||||||
|
per-turn `chat.ts` streaming path.)
|
||||||
|
|
||||||
|
### Already fine, no change needed
|
||||||
|
|
||||||
|
- **DB access**: `pgxpool.Pool` is a connection pool; concurrent queries from
|
||||||
|
multiple task goroutines are its normal use case.
|
||||||
|
- **Auto-continuation worker** ([continue.go](../../cmd/nomos/continue.go)):
|
||||||
|
already scoped per session (`pendingContinuation.SessionID`) — processes
|
||||||
|
its poll batch sequentially (5/tick) but never mixes state across
|
||||||
|
sessions. Sequential processing is a throughput nit, not a correctness bug;
|
||||||
|
not in scope here.
|
||||||
|
- **Task board** ([Tasks.svelte](../../web/src/pages/Tasks.svelte)): event-driven
|
||||||
|
refresh already handles any number of concurrently-changing tasks correctly
|
||||||
|
— it re-lists, it doesn't hold per-task live state.
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
### Fix 1 — session-scope the assent and destructive windows
|
||||||
|
|
||||||
|
Thread `sessionID` through to the MCP call sites. nomos already knows the
|
||||||
|
session id when it calls a tool ([agent.go:363](../../cmd/nomos/agent.go)); the
|
||||||
|
MCP wire protocol doesn't restrict tool-call args to the declared schema
|
||||||
|
(`argsMap` just unmarshals whatever JSON object arrives), so nomos can inject
|
||||||
|
an internal `_session_id` into the args it sends over the wire — invisible to
|
||||||
|
the model (never in the tool's `InputSchema`, so it never appears in what the
|
||||||
|
LLM sees or is asked to supply) but readable server-side.
|
||||||
|
|
||||||
|
- `agent.go`: build a wire-args copy with `_session_id` added just before
|
||||||
|
`a.client.callTool(...)` (leave the args used for history/logging
|
||||||
|
unmodified — the model's own tool-call record shouldn't show an internal
|
||||||
|
field it never set).
|
||||||
|
- `internal/mcp/server.go`: `assentWindowActive`/`openAssentWindow`/
|
||||||
|
`destructiveWindow*` gain a `sessionID` parameter; the key becomes
|
||||||
|
`assent_window.agent:<id>.session:<sessionID>` (and similarly for the
|
||||||
|
destructive window). Every call site (`run`, `request_execution`, the
|
||||||
|
`apt_upgrade`/`pct_create` sub-cases) reads `_session_id` from `argsMap`
|
||||||
|
and passes it through.
|
||||||
|
- `agent.go` `openAssentWindow` gains the same `sessionID` param, called from
|
||||||
|
its two existing call sites ([agent.go:253](../../cmd/nomos/agent.go), :269),
|
||||||
|
which are already inside `chatWith` and have `sessionID` in scope.
|
||||||
|
- Fallback: if `_session_id` is missing (defensive — shouldn't happen since
|
||||||
|
nomos always sets it), treat as "no window" (fail closed, require
|
||||||
|
approval) rather than falling back to the old agent-wide key.
|
||||||
|
|
||||||
|
### Fix 2 — per-session MCP client (remove the throughput bottleneck)
|
||||||
|
|
||||||
|
Replace the single global `*mcpClient` with a small **map of clients keyed
|
||||||
|
by session id**, created lazily on first tool call for that session and
|
||||||
|
evicted after a period of inactivity (e.g. 10 minutes past the session's last
|
||||||
|
activity — long enough to outlive a slow `run`, short enough not to leak
|
||||||
|
connections for abandoned tasks). Guard the map itself with a mutex (cheap —
|
||||||
|
only held for map lookup/insert, not for the duration of a call); each
|
||||||
|
individual client keeps its own `mu` scoped to *its own* session's calls,
|
||||||
|
so Task A's slow `run` only serializes Task A's own tool calls (which are
|
||||||
|
already inherently sequential within one turn — the agent loop calls tools
|
||||||
|
one at a time) and never blocks Task B.
|
||||||
|
|
||||||
|
- New `mcpClientPool` type in `cmd/nomos`: `get(sessionID) *mcpClient`
|
||||||
|
(creates+initializes on miss), `sweep()` (evicts idle clients, called on a
|
||||||
|
ticker alongside the existing continuation-worker ticker).
|
||||||
|
`"ephemeral"`/`""` session ids (no persisted session) get their own
|
||||||
|
dedicated client, not pooled per-request, to avoid a connection-per-message
|
||||||
|
churn for the no-DB-store path.
|
||||||
|
- `agent` holds the pool instead of one `client`; `handleQuery` (the
|
||||||
|
structured `/query` endpoint, [main.go:330](../../cmd/nomos/main.go)) picks a
|
||||||
|
short-lived or dedicated client the same way.
|
||||||
|
- No server-side change needed (per finding 2's analysis — the server has no
|
||||||
|
per-connection state to protect).
|
||||||
|
|
||||||
|
### Fix 3 — frontend: don't let a background stream corrupt the active view
|
||||||
|
|
||||||
|
Minimal, contained fix (not a rearchitecture): capture the session id a
|
||||||
|
`sendMessage` stream belongs to, and have its callback check that
|
||||||
|
`currentSession` still matches before mutating `messages`/`streaming`. If the
|
||||||
|
operator has navigated away, the stream's events are silently dropped from
|
||||||
|
the UI (the task keeps running server-side regardless — the events are also
|
||||||
|
flowing on the global stream, and if the operator navigates back,
|
||||||
|
`loadSessionMessages`'s poll + REST hydration picks up whatever landed while
|
||||||
|
they were away, same as it already does for auto-continuation).
|
||||||
|
|
||||||
|
- `chat.ts` `sendMessage`: capture `const streamSessionID = ...` once the
|
||||||
|
`'session'` event assigns it; every subsequent branch of the callback
|
||||||
|
(`tool_use`, `tool_result`, `text_delta`, `text`, `done`, `error`) first
|
||||||
|
checks `get(currentSession) === streamSessionID` (or the pre-assignment
|
||||||
|
optimistic session) before touching `messages`.
|
||||||
|
- `loadSessionMessages`: no change needed once the above guard exists — it
|
||||||
|
already correctly sets `messages`/`currentSession` for the task being
|
||||||
|
opened; the guard just stops the *other* task's stream from clobbering it
|
||||||
|
afterward.
|
||||||
|
- Out of scope for this pass: a genuine multi-pane "watch two tasks stream
|
||||||
|
live side by side" UI. Not needed for correctness — the Tasks board already
|
||||||
|
shows live status for every task via `workspace.ts`'s correctly-scoped
|
||||||
|
events; only the single-focus Chat transcript view needs this guard.
|
||||||
|
|
||||||
|
### Fix 4 (optional) — a concurrency/cost guardrail
|
||||||
|
|
||||||
|
Nothing currently stops an operator from starting many tasks in a tight loop,
|
||||||
|
each spending real LLM API budget in parallel. Consider a simple semaphore in
|
||||||
|
nomos (`NOMOS_MAX_CONCURRENT_TASKS`, default e.g. 5) that `handleChat` acquires
|
||||||
|
before starting a turn and releases on completion; over the cap, queue or
|
||||||
|
reject with a clear "N tasks already running, try again shortly" rather than
|
||||||
|
letting an unbounded burst hit OpenRouter. This is an operational safeguard,
|
||||||
|
not a correctness fix — flagged as optional/lower priority.
|
||||||
|
|
||||||
|
## Implementation order
|
||||||
|
|
||||||
|
1. **Fix 1 (assent/destructive window session-scoping)** — the only one that's
|
||||||
|
a genuine safety bug (auto-running unapproved actions in another task);
|
||||||
|
ship first regardless of anything else.
|
||||||
|
2. **Fix 3 (frontend stream guard)** — small, contained, fixes a visibly
|
||||||
|
broken UX (switching tasks looks corrupted) independent of Fix 2.
|
||||||
|
3. **Fix 2 (per-session MCP client pool)** — the throughput fix; more moving
|
||||||
|
parts (lifecycle/eviction), ship after the safety fix lands and is
|
||||||
|
verified, since both touch the same call sites (`agent.go` tool dispatch).
|
||||||
|
4. **Fix 4 (concurrency cap)** — optional, only if real usage shows a need.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- **Fix 1**: approve Task A's plan (open its window); concurrently start Task
|
||||||
|
B and have it attempt a config-mutation `run` command *without* approving
|
||||||
|
Task B's plan — confirm Task B's action is queued for approval (not
|
||||||
|
auto-run), while Task A's own subsequent steps keep auto-running.
|
||||||
|
`SELECT key FROM autonomy_settings WHERE key LIKE 'assent_window%'` should
|
||||||
|
show session-scoped keys.
|
||||||
|
- **Fix 2**: start Task A with a `run` step that sleeps ~60s; concurrently
|
||||||
|
start Task B with a trivial `get_entity` call; confirm Task B's tool result
|
||||||
|
returns immediately rather than waiting on Task A. Confirm the client map
|
||||||
|
evicts idle entries (`sweep()` logged, connection count doesn't grow
|
||||||
|
unbounded across many sequential tasks).
|
||||||
|
- **Fix 3**: start Task A, before it finishes click into Task B on the
|
||||||
|
board, confirm Task B's transcript stays correct (no Task-A tool calls
|
||||||
|
appended) and `currentSession` doesn't flip back to Task A when its stream
|
||||||
|
eventually completes in the background. Navigate back to Task A afterward
|
||||||
|
and confirm its full transcript (including what happened while unwatched)
|
||||||
|
loads correctly via REST.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **Idle eviction window for Fix 2**: 10 minutes was a guess balancing
|
||||||
|
"outlive a slow `run`" against "don't leak connections." Worth checking
|
||||||
|
actual `run` durations in production (`executions.duration_ms`) before
|
||||||
|
picking a final number.
|
||||||
|
- **Fix 4's cap and behavior on overflow**: queue vs. reject vs. no cap at
|
||||||
|
all — depends on real usage patterns once concurrent tasks are actually
|
||||||
|
safe (Fix 1) and performant (Fix 2). Defer the decision until there's
|
||||||
|
data.
|
||||||
|
- **Destructive-window session-scoping**: bundle into Fix 1 (same shape, same
|
||||||
|
PR) or treat as a follow-up given its narrower blast radius? Leaning bundle
|
||||||
|
— it's the same three-line change pattern applied to one more function.
|
||||||
312
plans/done/2026-07-11-goal-oriented-chat-control-panel.md
Normal file
312
plans/done/2026-07-11-goal-oriented-chat-control-panel.md
Normal file
@@ -0,0 +1,312 @@
|
|||||||
|
# 2026-07-11 — Tasks: the chat page as goal-structured autonomous work
|
||||||
|
|
||||||
|
**Status:** Done — 2026-07-11. All 7 phases shipped and deployed (SHA
|
||||||
|
`e30813a`): task schema + entity anchor, `entity.touched`/`involves` live
|
||||||
|
tracking, the `complete_task`/knowledge-retrieval loop, structured
|
||||||
|
`propose_plan`/`update_plan_step` (with an append-not-replace fix for
|
||||||
|
mid-flight re-proposals), `ask_operator` pause/resume, the Tasks board, and
|
||||||
|
the live `TaskContextPanel`. Follow-up hardening tracked separately in
|
||||||
|
[concurrent-task-execution](../2026-07-11-concurrent-task-execution.md).
|
||||||
|
Supersedes the sidebar-only framing and the Chat portion of
|
||||||
|
[control-room-webui](../2026-07-08-control-room-webui.md), which described
|
||||||
|
chat as a free-form session list.
|
||||||
|
|
||||||
|
## The vision (operator, distilled)
|
||||||
|
|
||||||
|
> Structure the whole chat page as **tasks**. A task is a card — you see its
|
||||||
|
> status (running / completed / failed), its description. A task *is* a goal:
|
||||||
|
> "install the service", "give me the key status of X". The agent takes the
|
||||||
|
> goal, finds what it needs, **proposes a plan, the operator approves it once —
|
||||||
|
> that single approval is the only one needed — and the agent then executes the
|
||||||
|
> whole plan autonomously until the goal is achieved.** Every task has a
|
||||||
|
> completion status: successful or not, and its **learnings move to knowledge**,
|
||||||
|
> attached via **relationships** to the entities that were involved, so future
|
||||||
|
> tasks — successful or unsuccessful — make the agent better over time. Inside a
|
||||||
|
> task is the conversation (tools, thinking, questions if needed); the sidebar
|
||||||
|
> shows the live context: which entities the agent is exploring, the steps and
|
||||||
|
> their status, whether the task succeeded, and the knowledge it recorded — all
|
||||||
|
> populated in **real time** as the agent works.
|
||||||
|
|
||||||
|
Three pillars: **task as the unit**, **one approval → autonomous execution**,
|
||||||
|
**a knowledge loop that compounds**.
|
||||||
|
|
||||||
|
## The reframe
|
||||||
|
|
||||||
|
Today a "session" is a title + a flat message list
|
||||||
|
([migrations/015](../../migrations/015_agent_sessions.up.sql)); a "plan" is prose
|
||||||
|
the model types; there is no goal, status, outcome, or step object. We elevate
|
||||||
|
the session into a **task**:
|
||||||
|
|
||||||
|
- **A task = a session with a goal, a plan, a lifecycle status, and an
|
||||||
|
outcome.** One task per chat. The chat page becomes a **task board** of
|
||||||
|
status cards; opening a card shows the task: conversation in the center, live
|
||||||
|
context in the sidebar.
|
||||||
|
- **The plan is approved once.** Machinery already exists — the assent window +
|
||||||
|
event-driven auto-continuation shipped in
|
||||||
|
[autonomous-plan-execution](2026-07-10-autonomous-plan-execution.md)
|
||||||
|
([continue.go](../../cmd/nomos/continue.go), [assent.go](../../cmd/nomos/assent.go))
|
||||||
|
already turn a single approval into an autonomy grant the agent runs to
|
||||||
|
completion. This plan gives that flow a **structured surface**: the one thing
|
||||||
|
the operator approves is a named, stepped plan, and progress is visible.
|
||||||
|
- **On completion the task deposits knowledge**, linked by relationships to the
|
||||||
|
entities involved *and to the task itself*, tagged success/failure — and
|
||||||
|
**future tasks read it back at planning time.** The substrate exists:
|
||||||
|
`upsert_knowledge` writes a knowledge doc-entity and a `documents`
|
||||||
|
relationship ([server.go:1519](../../internal/mcp/server.go));
|
||||||
|
`get_entity_knowledge` reads it ([server.go:170](../../internal/mcp/server.go)).
|
||||||
|
We add task-linkage, an outcome flavor, and retrieval-at-planning.
|
||||||
|
|
||||||
|
## Builds on / aligns with
|
||||||
|
|
||||||
|
- [general-gated-execution](../2026-07-10-general-gated-execution.md) — the
|
||||||
|
classifier + `run` primitive is the execution substrate; a plan step is just
|
||||||
|
a described unit of work mapping to a `run`/`request_execution` call. **No
|
||||||
|
fixed step enum.**
|
||||||
|
- [autonomous-plan-execution](2026-07-10-autonomous-plan-execution.md) —
|
||||||
|
the single-approval autonomy window + auto-continuation loop.
|
||||||
|
- The knowledge tools + relationships graph (`upsert_knowledge`,
|
||||||
|
`get_entity_knowledge`, `get_relations`, the temporal `relationships` table).
|
||||||
|
|
||||||
|
## Data model (migration `018_tasks.up.sql`)
|
||||||
|
|
||||||
|
Elevate the session into a task; add plan steps, questions, and the
|
||||||
|
task→knowledge linkage.
|
||||||
|
|
||||||
|
```sql
|
||||||
|
ALTER TABLE agent_sessions
|
||||||
|
ADD COLUMN goal TEXT NOT NULL DEFAULT '',
|
||||||
|
ADD COLUMN status TEXT NOT NULL DEFAULT 'active',
|
||||||
|
-- active | planning | awaiting_approval | executing
|
||||||
|
-- | awaiting_input | done | failed | abandoned
|
||||||
|
ADD COLUMN outcome TEXT, -- success | failure | partial (NULL until done)
|
||||||
|
ADD COLUMN summary TEXT NOT NULL DEFAULT '', -- one-line result, shown on the card
|
||||||
|
ADD COLUMN entity_id UUID; -- the task's OWN entity (type 'task'), for
|
||||||
|
-- knowledge/relationship linkage (see below)
|
||||||
|
|
||||||
|
CREATE TABLE session_plan_steps (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||||
|
seq INT NOT NULL,
|
||||||
|
title TEXT NOT NULL,
|
||||||
|
detail TEXT NOT NULL DEFAULT '',
|
||||||
|
status TEXT NOT NULL DEFAULT 'pending',
|
||||||
|
-- pending | running | done | failed | skipped | blocked
|
||||||
|
execution_id UUID,
|
||||||
|
target_slug TEXT,
|
||||||
|
started_at TIMESTAMPTZ,
|
||||||
|
finished_at TIMESTAMPTZ,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now()
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_plan_steps_session ON session_plan_steps(session_id, seq);
|
||||||
|
|
||||||
|
CREATE TABLE session_questions (
|
||||||
|
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
session_id UUID NOT NULL REFERENCES agent_sessions(id) ON DELETE CASCADE,
|
||||||
|
prompt TEXT NOT NULL,
|
||||||
|
context JSONB NOT NULL DEFAULT '{}', -- { entities:[], options:[], why:"" }
|
||||||
|
status TEXT NOT NULL DEFAULT 'open', -- open | answered | dismissed
|
||||||
|
answer TEXT,
|
||||||
|
created_at TIMESTAMPTZ NOT NULL DEFAULT now(),
|
||||||
|
answered_at TIMESTAMPTZ
|
||||||
|
);
|
||||||
|
CREATE INDEX idx_questions_session_open
|
||||||
|
ON session_questions(session_id) WHERE status = 'open';
|
||||||
|
```
|
||||||
|
|
||||||
|
**The task as an entity.** Each task gets a row in `entities` (type `task`,
|
||||||
|
slug `task:<short-id>`), stored in `agent_sessions.entity_id`. This is what
|
||||||
|
makes the knowledge loop use the *existing* graph machinery unchanged:
|
||||||
|
knowledge and involved-entity links hang off the task entity via
|
||||||
|
`relationships`, exactly like any other entity.
|
||||||
|
|
||||||
|
## Task lifecycle
|
||||||
|
|
||||||
|
```
|
||||||
|
created → planning → awaiting_approval → executing ⇄ awaiting_input → done
|
||||||
|
│ (outcome:
|
||||||
|
└──────────────→ failed success/
|
||||||
|
failure/
|
||||||
|
partial)
|
||||||
|
```
|
||||||
|
|
||||||
|
- **planning**: agent calls `get_entity_knowledge` on the target(s) first (prior
|
||||||
|
learnings), then `set_goal` + `propose_plan`.
|
||||||
|
- **awaiting_approval**: the plan is the single approval gate. Operator approves
|
||||||
|
→ opens the assent window (existing) → **executing**.
|
||||||
|
- **executing**: steps flip pending→running→done via `update_plan_step` and the
|
||||||
|
execution→step auto-close (below); the agent runs autonomously
|
||||||
|
(auto-continuation) with no per-step re-approval.
|
||||||
|
- **awaiting_input**: only when the agent hits a real decision → `ask_operator`;
|
||||||
|
answering resumes execution.
|
||||||
|
- **done/failed**: agent sets `outcome` + `summary` and deposits knowledge.
|
||||||
|
|
||||||
|
## Single approval → autonomous execution
|
||||||
|
|
||||||
|
Already the behaviour of the assent window + auto-continuation. This plan makes
|
||||||
|
the **approved object** a structured plan rather than an individual command:
|
||||||
|
approving the plan (one click / one "go ahead") authorizes every
|
||||||
|
read-only + config-mutation step in it. Destructive steps still require typed
|
||||||
|
confirmation *unless* named in the approved plan (the existing pre-authorized
|
||||||
|
destructive-step rule). Nothing new in the execution engine — we're giving it a
|
||||||
|
legible unit to approve and to show progress against.
|
||||||
|
|
||||||
|
## The knowledge loop (capture → link → retrieve)
|
||||||
|
|
||||||
|
**Capture (task end).** On `done`/`failed`, the agent (nudged by SOUL, enforced
|
||||||
|
by a server-side fallback) calls `upsert_knowledge` with the concrete learning —
|
||||||
|
what worked, what didn't, the gotcha — and we link the resulting knowledge
|
||||||
|
doc-entity to:
|
||||||
|
- the **entities involved** (already supported via `about`), and
|
||||||
|
- the **task entity** (`agent_sessions.entity_id`), via a new
|
||||||
|
`outcome_of` / `produced_by` relationship, tagged
|
||||||
|
`{"outcome":"success|failure"}`.
|
||||||
|
|
||||||
|
**Link.** Involved entities are captured cheaply: every `entity.touched` (below)
|
||||||
|
records a `relationships` edge `task —involved→ entity`. So a task's entity
|
||||||
|
neighborhood *is* its involved-entity set — queryable with the existing
|
||||||
|
`get_relations`.
|
||||||
|
|
||||||
|
**Retrieve (task start).** At **planning**, before proposing, the agent pulls
|
||||||
|
prior knowledge for the target entities (`get_entity_knowledge`) — which now
|
||||||
|
surfaces both successful and failed prior tasks (the outcome tag lets it weight
|
||||||
|
"last time `apt install docker.io` failed on Debian, used get.docker.com
|
||||||
|
instead"). This is the compounding: each task's outcome becomes the next task's
|
||||||
|
prior. SOUL makes this the first planning move.
|
||||||
|
|
||||||
|
## UI
|
||||||
|
|
||||||
|
### Task board (replaces the raw session rail / empty chat state)
|
||||||
|
|
||||||
|
[Sessions.svelte](../../web/src/pages/Sessions.svelte) /
|
||||||
|
[SessionRail.svelte](../../web/src/lib/components/SessionRail.svelte) become a
|
||||||
|
**board of task cards**. Each card:
|
||||||
|
- goal as the title, one-line `summary`,
|
||||||
|
- a **status pill** (running ◐ / awaiting you / done ✓ / failed ✗) with the
|
||||||
|
step progress (`4/6`),
|
||||||
|
- outcome color on completion, knowledge-count badge (♦ 2 learned),
|
||||||
|
- click → open the task.
|
||||||
|
|
||||||
|
Grouped/filterable by status (Running, Needs input, Done, Failed). "New task"
|
||||||
|
replaces "new chat" — the empty state asks for a goal.
|
||||||
|
|
||||||
|
### Task detail = conversation + live context sidebar
|
||||||
|
|
||||||
|
Center column: the existing chat transcript (tools, thinking, questions inline)
|
||||||
|
— unchanged rendering ([Chat.svelte](../../web/src/pages/Chat.svelte)).
|
||||||
|
|
||||||
|
Right sidebar becomes `TaskContextPanel.svelte`, populated **in real time**, top
|
||||||
|
to bottom:
|
||||||
|
1. **GoalHeader** — goal + status pill + outcome (once done); editable goal.
|
||||||
|
2. **PlanProgress** — ordered steps, live status icons, `4/6` bar, click a step
|
||||||
|
→ scroll chat to its tool call / open its execution output.
|
||||||
|
3. **OperatorQuestion** — pinned structured card when a question is open: prompt,
|
||||||
|
`why`, context-entity chips (→ EntitySheet), option buttons or free-text.
|
||||||
|
Answering POSTs the answer and resumes the agent. Same card also renders
|
||||||
|
inline in the transcript at the point it was raised. (The operator's
|
||||||
|
"structured component with relevant context.")
|
||||||
|
4. **LiveEntityPanel** — the [SessionGraph](../../web/src/lib/components/SessionGraph.svelte)
|
||||||
|
upgraded from passive to live: `entity.touched` → the node **pulses** +
|
||||||
|
"now touching `lxc:foo`"; `health.changed` → recolor + transient
|
||||||
|
`healthy→degraded` diff badge.
|
||||||
|
5. **Outcome & Knowledge** — on completion: success/failure banner, the
|
||||||
|
`summary`, and the knowledge notes recorded (links to the knowledge
|
||||||
|
entities), i.e. the [SessionDigest](../../web/src/lib/components/SessionDigest.svelte)
|
||||||
|
evolved into a task-outcome card.
|
||||||
|
|
||||||
|
## Real-time event contract (global `/events/stream`)
|
||||||
|
|
||||||
|
The panel is driven by the **always-on** [events stream](../../web/src/lib/stores/events.ts),
|
||||||
|
not the per-turn chat SSE — so it stays live during server-side
|
||||||
|
auto-continuation (when no chat turn is open) and survives a tab reload. New
|
||||||
|
`type`s, each carrying `correlation_id = session_id`:
|
||||||
|
|
||||||
|
| type | data |
|
||||||
|
| ---- | ---- |
|
||||||
|
| `task.status` | `{ status, outcome?, summary? }` |
|
||||||
|
| `goal.set` | `{ goal }` |
|
||||||
|
| `plan.proposed` | `{ steps:[{seq,title,detail,target_slug}] }` |
|
||||||
|
| `plan.step.started` / `plan.step.finished` | `{ step_id, seq, status, execution_id? }` |
|
||||||
|
| `question.raised` / `question.answered` | `{ question_id, prompt?, context?, answer? }` |
|
||||||
|
| `entity.touched` | `{ slug, tool }` |
|
||||||
|
| `knowledge.recorded` | `{ title, about, outcome }` |
|
||||||
|
|
||||||
|
`entity.touched` is emitted from the `withActivityLogging` wrapper
|
||||||
|
([server.go:832](../../internal/mcp/server.go)) — it wraps every tool call, so
|
||||||
|
touched-entity tracking needs **zero agent changes**; it also writes the
|
||||||
|
`task —involved→ entity` relationship. `health.changed` already exists.
|
||||||
|
|
||||||
|
## Agent surface (new MCP tools + SOUL)
|
||||||
|
|
||||||
|
Thin declarations that write the tables/relationships and publish the event
|
||||||
|
in-process (event and row commit together):
|
||||||
|
- `set_goal(goal)`
|
||||||
|
- `propose_plan(steps:[{title,detail?,target_slug?}])`
|
||||||
|
- `update_plan_step(seq,status,execution_id?)` — plus the execution's terminal
|
||||||
|
status **auto-closes** its linked step where
|
||||||
|
[phase3.go](../../internal/httpapi/phase3.go) finalizes executions (belt and
|
||||||
|
suspenders).
|
||||||
|
- `ask_operator(prompt,options?,context_entities?,why?)` — creates the question,
|
||||||
|
status→`awaiting_input`, ends the turn; answer resumes via the existing
|
||||||
|
assent/continuation path.
|
||||||
|
- `complete_task(outcome,summary)` — sets outcome/summary, status→done/failed;
|
||||||
|
server enforces "a completed task must have deposited ≥1 knowledge note"
|
||||||
|
(fallback: auto-summarize into one if the model forgot).
|
||||||
|
|
||||||
|
SOUL: "Every task has a goal. **First**, read prior knowledge for the target
|
||||||
|
entities (`get_entity_knowledge`) — learn from past tasks, successful or not.
|
||||||
|
Then `set_goal` + `propose_plan`. Execute autonomously after approval, marking
|
||||||
|
steps. Ask via `ask_operator` only for real decisions. When the goal is
|
||||||
|
verified, `complete_task` with the outcome and record what you learned."
|
||||||
|
|
||||||
|
## Implementation order
|
||||||
|
|
||||||
|
1. **Migration `018` + task-entity creation** (a `task` entity per session) +
|
||||||
|
store methods. Sessions gain goal/status/outcome/summary; no behaviour change.
|
||||||
|
2. **`entity.touched` + `task —involved→ entity`** from `withActivityLogging` —
|
||||||
|
cheapest live win; graph starts pulsing, involved-set is captured for free.
|
||||||
|
3. **Knowledge loop close**: `complete_task` + retrieval-at-planning in SOUL +
|
||||||
|
outcome-tagged `outcome_of` link. Makes tasks compound.
|
||||||
|
4. **`set_goal`/`propose_plan`/`update_plan_step`** + execution→step auto-close.
|
||||||
|
5. **`ask_operator`** end-to-end (tool → question → pinned card inline+panel →
|
||||||
|
answer resumes).
|
||||||
|
6. **UI: TaskContextPanel** (GoalHeader, PlanProgress, OperatorQuestion,
|
||||||
|
LiveEntityPanel, Outcome/Knowledge) + `workspace.ts` store + REST hydration
|
||||||
|
(`GET /sessions/{id}/{plan,questions}`).
|
||||||
|
7. **UI: Task board** — session rail/list → status-card board, "new task" flow.
|
||||||
|
|
||||||
|
Each step ships value: 2 = live entity awareness; 3 = compounding knowledge;
|
||||||
|
4-5 = plan progress + interactive questions; 6-7 = the full task surface.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- Run "deploy TypeType as an LXC on strong" as a task. Expect: at planning the
|
||||||
|
agent reads prior knowledge for `host:strong`; `propose_plan` renders steps;
|
||||||
|
operator approves **once**; steps flip live; the touched node pulses; a
|
||||||
|
mid-flow ambiguity surfaces as an `ask_operator` card answered in the panel;
|
||||||
|
on success `complete_task` sets outcome=success, deposits a knowledge note
|
||||||
|
linked to `lxc:typetype`, `host:strong`, and the task entity.
|
||||||
|
- Start a **second** task touching `host:strong`; confirm the first task's
|
||||||
|
knowledge surfaces at planning (`get_entity_knowledge`) — the compounding loop.
|
||||||
|
- Reload the tab mid-execution → panel rehydrates from REST and keeps updating
|
||||||
|
from the global stream (proves it isn't chat-SSE-bound).
|
||||||
|
- Board shows the task moving Running → Done with the right outcome color and
|
||||||
|
knowledge badge. `SELECT status, outcome FROM agent_sessions` shows a real
|
||||||
|
lifecycle, not all `active`.
|
||||||
|
- `get_relations` on the task entity returns its involved entities + produced
|
||||||
|
knowledge.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **One task per session vs sequential tasks in a chat** — v1: one task = one
|
||||||
|
session (matches "each chat is a goal"). A new goal starts a new task.
|
||||||
|
Multi-task threads are a later extension.
|
||||||
|
- **Failure knowledge weighting** — do we just tag `outcome:failure` and let the
|
||||||
|
model judge, or add explicit "avoid this" surfacing at planning? Lean tag-only
|
||||||
|
first; revisit if the agent repeats known-failed approaches.
|
||||||
|
- **`complete_task` enforcement** — hard-require a knowledge note (block
|
||||||
|
completion) or soft (auto-generate a stub)? Lean soft, so a trivial "give me
|
||||||
|
status" task isn't forced to invent a learning.
|
||||||
|
- **Board vs thread** for very short tasks ("key status of X") — a status query
|
||||||
|
is a degenerate task (no plan, instant done). Render it as a lightweight card
|
||||||
|
that never shows an approval, so the board isn't cluttered with heavyweight
|
||||||
|
chrome for one-shot questions.
|
||||||
256
plans/done/2026-07-11-task-completion-safety-net.md
Normal file
256
plans/done/2026-07-11-task-completion-safety-net.md
Normal file
@@ -0,0 +1,256 @@
|
|||||||
|
# Task completion safety net: every live task is stuck "Running"
|
||||||
|
|
||||||
|
Status: Done — 2026-07-12. Fixes 1-3 implemented, built, tested
|
||||||
|
(`go build ./...`, `go test ./cmd/nomos/...`), committed (`3b9c75f`),
|
||||||
|
deployed, and verified live (see Verification below — fresh trivial Q&A
|
||||||
|
sessions now reach `done` immediately; a goal-bearing session that went
|
||||||
|
idle was correctly nudged and auto-resolved by the existing resume-failure
|
||||||
|
path). Fix 4 (backfill) was replaced with deletion — see "Fix 4, revised"
|
||||||
|
below; the original backfill-with-a-fabricated-outcome approach was never
|
||||||
|
run.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Fix the root cause of a production-wide defect found while UI-testing
|
||||||
|
[`2026-07-11-ui-review-ia-usability.md`](2026-07-11-ui-review-ia-usability.md):
|
||||||
|
every session on the live task board shows as "Running" forever. Traced
|
||||||
|
through `cmd/nomos/` and confirmed against the running database — this is
|
||||||
|
not a frontend bug (the board correctly reflects real `agent_sessions.status`
|
||||||
|
values). It's an agent-behavior gap: the model almost never calls the
|
||||||
|
lifecycle tools (`set_goal` / `propose_plan` / `complete_task`) that the
|
||||||
|
task-board feature (shipped today,
|
||||||
|
[`done/2026-07-11-goal-oriented-chat-control-panel.md`](2026-07-11-goal-oriented-chat-control-panel.md))
|
||||||
|
depends on to know a task is finished.
|
||||||
|
|
||||||
|
## Evidence
|
||||||
|
|
||||||
|
Queried the live nomos API directly (`curl localhost:8092/sessions` and
|
||||||
|
per-session transcripts) against the running mac-mini stack:
|
||||||
|
|
||||||
|
- **50/50 live sessions**: 49 `active`, 1 `planning`. Zero have ever reached
|
||||||
|
`executing`, `awaiting_input`, `done`, or `failed`.
|
||||||
|
- Across all 50 sessions: **`set_goal` called once. `propose_plan` called
|
||||||
|
zero times. `complete_task` called zero times.**
|
||||||
|
- The dominant pattern (43/50 sessions, 2-message transcripts) is a single
|
||||||
|
quick exchange: operator asks something narrow ("what's the hostname of
|
||||||
|
lxc:caddy?"), the model runs one read tool (`run hostname`), answers in
|
||||||
|
plain text, and the turn ends — no lifecycle tool call at all. This is
|
||||||
|
exactly the case
|
||||||
|
[`nomos/SOUL.md:105-109`](../../nomos/SOUL.md#L105) calls out by name
|
||||||
|
("a trivial read-only task... is a degenerate case... answer it and
|
||||||
|
`complete_task` with a one-line summary") — the instruction exists and is
|
||||||
|
explicit, and the model skips it anyway, consistently.
|
||||||
|
- The one session that *did* call `set_goal` (a fleet health check) did
|
||||||
|
substantial real research (`get_health_summary`, `get_state_snapshot`,
|
||||||
|
`get_signal_history`, `list_lxcs`), gave the operator a full structured
|
||||||
|
answer, and then also just stopped — no `propose_plan`, no
|
||||||
|
`complete_task`. Status: stuck at `planning` since 2026-07-11T11:35, still
|
||||||
|
showing "Running" on the board.
|
||||||
|
|
||||||
|
This means the board's "N Running / 0 Done / 0 Failed" isn't a fluke or an
|
||||||
|
edge case — it's the default outcome for essentially every task the system
|
||||||
|
has ever run. The feature as designed (terminal state is 100% dependent on
|
||||||
|
the model remembering to call one specific tool) doesn't hold up against
|
||||||
|
real model behavior, even with an explicit prompt instruction already in
|
||||||
|
place.
|
||||||
|
|
||||||
|
## Where this lives in the code
|
||||||
|
|
||||||
|
`cmd/nomos/agent.go`'s `chatWith` has exactly one place a turn ends with a
|
||||||
|
plain-text answer and no tool calls:
|
||||||
|
|
||||||
|
```go
|
||||||
|
// agent.go:359-368
|
||||||
|
if len(msg.ToolCalls) == 0 {
|
||||||
|
emit(agentEvent{Type: "text", Data: msg.Content, SessionID: sessionID})
|
||||||
|
emit(agentEvent{Type: "done", ...})
|
||||||
|
return
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
This is reached for the trivial-Q&A case (a turn that made zero or a few
|
||||||
|
read-only tool calls this iteration, then answered in text) and is where
|
||||||
|
43/50 of the stuck sessions are produced. There's a second, rarer exit at
|
||||||
|
the step-limit fallback (`agent.go:487-494`, `finalSummary`) with the same
|
||||||
|
gap.
|
||||||
|
|
||||||
|
Neither exit currently checks whether the session ever reached a terminal
|
||||||
|
state — the turn just ends, and `agent_sessions.status` is left wherever it
|
||||||
|
was (usually `active`, its creation-time default,
|
||||||
|
[`store.go:71,84`](../../cmd/nomos/store.go#L71)).
|
||||||
|
|
||||||
|
## Design
|
||||||
|
|
||||||
|
Two different failure shapes need two different fixes — collapsing them
|
||||||
|
into one heuristic would either auto-close genuinely in-progress structured
|
||||||
|
tasks or fail to catch the trivial-Q&A majority.
|
||||||
|
|
||||||
|
**1. Trivial/no-lifecycle-tool sessions (the 43/50 case) — auto-complete
|
||||||
|
inline, same turn.**
|
||||||
|
If a turn ends with a plain-text response (`len(msg.ToolCalls) == 0`, the
|
||||||
|
existing exit at `agent.go:359`) AND this session has never called
|
||||||
|
`set_goal` in its history, that's strong evidence this was never meant to
|
||||||
|
be a structured multi-step task — it's a one-shot question that got
|
||||||
|
answered. Call `store.completeTask` server-side right there, before the
|
||||||
|
`return`, with `outcome="success"` and a summary derived from the response
|
||||||
|
text (first ~120 chars, same truncation pattern
|
||||||
|
`buildContinuationNote` already uses at
|
||||||
|
[`continue.go:203-205`](../../cmd/nomos/continue.go#L203)). No LLM call needed
|
||||||
|
— this is a mechanical default, not a judgment call, matching the "trivial
|
||||||
|
task" case SOUL.md already describes.
|
||||||
|
|
||||||
|
If the session *has* called `set_goal` (meaning the model explicitly framed
|
||||||
|
this as a task, e.g. the fleet-health-check session), auto-completing on
|
||||||
|
the very next plain-text turn is riskier — the model may reasonably expect
|
||||||
|
to be asked something next. Skip the inline auto-complete for these; case 2
|
||||||
|
covers them.
|
||||||
|
|
||||||
|
**2. Structured (goal/plan set) sessions that stall — idle sweep, not
|
||||||
|
inline.**
|
||||||
|
Extend the existing `runContinuationWorker` ticker
|
||||||
|
([`continue.go:41-57`](../../cmd/nomos/continue.go#L41), already polling every
|
||||||
|
4s for a different purpose) with a second, coarser sweep — e.g. every 5
|
||||||
|
minutes — that finds sessions where:
|
||||||
|
- `status` is `active`, `planning`, or `executing` (not already terminal or
|
||||||
|
`awaiting_input`, which has its own resolution path), AND
|
||||||
|
- `set_goal` was called (this is a real task, not case 1), AND
|
||||||
|
- `last_active_at` is older than some idle threshold (start with 15
|
||||||
|
minutes — long enough that it's not still mid-turn, short enough that the
|
||||||
|
board doesn't lie for hours).
|
||||||
|
|
||||||
|
First idle hit: inject a system note next time nothing else touches the
|
||||||
|
session ("[System: this task has been idle for N minutes with no
|
||||||
|
`complete_task` call. If the goal is done, call it now with a summary. If
|
||||||
|
you're genuinely still working, ignore this.]") the same way
|
||||||
|
`buildContinuationNote` already injects notes into resumed sessions — reuse
|
||||||
|
`resumeSession`'s live-persist pattern
|
||||||
|
([`continue.go:106-196`](../../cmd/nomos/continue.go#L106)) so the nudge and
|
||||||
|
the model's response show up in the transcript, not silently.
|
||||||
|
|
||||||
|
If a second idle sweep finds the same session still not completed (i.e.
|
||||||
|
the nudge didn't take), auto-complete it directly with
|
||||||
|
`outcome="partial"` and a summary noting it was auto-closed after an
|
||||||
|
unanswered nudge — same reasoning as `resumeSession`'s existing
|
||||||
|
"give the task a real, operator-visible terminal state instead of leaving
|
||||||
|
it silently stuck forever" logic at
|
||||||
|
[`continue.go:179-193`](../../cmd/nomos/continue.go#L179), which already does
|
||||||
|
exactly this for a different failure mode (a resume that produces no
|
||||||
|
response). This is the same architectural pattern, applied to a session
|
||||||
|
that produces responses but never a terminal tool call.
|
||||||
|
|
||||||
|
**3. Leave `ask_operator` and gated-execution flows alone.** Those already
|
||||||
|
have real terminal signals (`awaiting_input` status, the continuation
|
||||||
|
worker's assent-window logic) — this plan only targets sessions that fall
|
||||||
|
through with no lifecycle signal at all.
|
||||||
|
|
||||||
|
## Fix plan
|
||||||
|
|
||||||
|
1. **Inline safety net (case 1)** — in `chatWith`'s plain-text exit
|
||||||
|
(`agent.go:359`), check `set_goal` was never called for this session
|
||||||
|
(cheap: track a bool while replaying `history` in the same function, no
|
||||||
|
extra query — the loop at `agent.go:209-226` already walks every
|
||||||
|
persisted message and could flag `sawSetGoal` while extracting tool
|
||||||
|
calls). If not sawSetGoal, call `completeTask` before returning.
|
||||||
|
2. **Idle sweep (case 2)** — new ticker in `continue.go` (or extend the
|
||||||
|
existing one with a slower secondary tick), a new store query
|
||||||
|
(`store.staleGoalSessions(ctx, idleThreshold)` mirroring
|
||||||
|
`pendingContinuations`'s shape), and reuse of `resumeSession`'s
|
||||||
|
live-persist injection for the nudge.
|
||||||
|
3. **Second-strike auto-close (case 2, continued)** — track nudge count (a
|
||||||
|
new `agent_sessions` column, e.g. `completion_nudges int default 0`, or
|
||||||
|
reuse the existing `summary`/attributes json instead of a schema change
|
||||||
|
if that's preferable) so the sweep can tell "never nudged" from "nudged
|
||||||
|
once already, still stuck."
|
||||||
|
4. **Backfill** — the 50 already-stuck live sessions won't get fixed by new
|
||||||
|
code alone (they're historical). One-time cleanup: run the same
|
||||||
|
case-1/case-2 classification against existing rows once the code ships,
|
||||||
|
so the board doesn't show 50 permanently-orphaned "Running" cards on top
|
||||||
|
of new correctly-terminating ones. This should be a script, not a manual
|
||||||
|
UPDATE — the classification logic will already exist in Go.
|
||||||
|
|
||||||
|
## Fix 4, revised: deletion instead of backfill
|
||||||
|
|
||||||
|
The plan as written proposed backfilling the 50 already-stuck sessions with
|
||||||
|
a mechanically-assigned outcome (`success` for case 1, `partial` for case
|
||||||
|
2). When it came time to execute that, the operator raised a better
|
||||||
|
question: these were overwhelmingly one-off test/smoke-test sessions
|
||||||
|
("hi", "what's the hostname of lxc:caddy?") with no lasting value —
|
||||||
|
assigning them a fabricated `success` outcome would make the task board
|
||||||
|
lie in the opposite direction (claiming verified success on things nobody
|
||||||
|
verified). The operator's call: delete them instead of backfilling a
|
||||||
|
guessed outcome, with one condition — don't lose any recorded knowledge.
|
||||||
|
|
||||||
|
Before deleting anything, verified directly against the database (not
|
||||||
|
assumed from reading the code):
|
||||||
|
- Zero `documents` relationship edges exist linking any of the candidate
|
||||||
|
sessions to any `knowledge_entities` row.
|
||||||
|
- Zero `upsert_knowledge` calls appear anywhere in the candidate sessions'
|
||||||
|
transcripts.
|
||||||
|
- Zero `knowledge_entities` rows exist system-wide mentioning the one
|
||||||
|
topic (`typetype`) the operator specifically asked to preserve.
|
||||||
|
|
||||||
|
`deleteSession` (`store.go:293`, already the live code path behind the
|
||||||
|
UI's "Delete task" button — reused as-is, not reimplemented) removes the
|
||||||
|
session, its messages, its own task entity, and that entity's relationship
|
||||||
|
edges — it never touches `knowledge_entities` rows or entities the task
|
||||||
|
merely referenced (e.g. `lxc:typetype` itself), only the provenance edges
|
||||||
|
back to the now-deleted task. Given the verification above, this was safe:
|
||||||
|
there was nothing to preserve because nothing had ever been recorded.
|
||||||
|
|
||||||
|
Executed in two batches, both via the same `DELETE /sessions/:id` route:
|
||||||
|
- **47 sessions** — the original candidate set from `curl
|
||||||
|
localhost:8092/sessions`, all non-`done`/`failed` at the time.
|
||||||
|
- **6 more sessions** — found *after* the first batch, when they surfaced
|
||||||
|
on the task board: `listSessions` (`store.go:193`) hardcodes
|
||||||
|
`ORDER BY last_active_at DESC LIMIT 50` with no pagination, so the
|
||||||
|
original audit's "50 sessions total" was actually "the 50 most
|
||||||
|
recently active" — it silently excluded 6 older stuck sessions from
|
||||||
|
2026-07-08 (predating the task-board feature entirely, same trivial
|
||||||
|
"hi"/smoke-test pattern). Worth knowing about `listSessions`'s cap for
|
||||||
|
any future audit of this table — a `count(*)` query directly against
|
||||||
|
the database is the only way to get a true total.
|
||||||
|
|
||||||
|
Final state: `agent_sessions` holds exactly 3 rows — the two `done` and
|
||||||
|
one `failed` sessions produced during live verification of fixes 1-3.
|
||||||
|
|
||||||
|
## Implementation order
|
||||||
|
|
||||||
|
1. Fix 1 (inline safety net) first — it's the highest-leverage, lowest-risk
|
||||||
|
change (self-contained, no schema change, covers 43/50 of the evidence).
|
||||||
|
2. Fix 2+3 (idle sweep + second-strike) — needs the schema decision
|
||||||
|
(new column vs. attribute) settled first; smaller blast radius than 1
|
||||||
|
but touches the ticker/worker machinery, deserves its own review pass.
|
||||||
|
3. Fix 4 (backfill) last, once 1-3 are deployed and verified live — running
|
||||||
|
it before the code ships would just recreate the same gap for new
|
||||||
|
sessions created in between.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- After fix 1: start a few trivial one-shot chats against the live agent
|
||||||
|
(`hostname`-style questions), confirm each session reaches `status=done`
|
||||||
|
immediately after the answer, via `curl localhost:8092/sessions/:id` or
|
||||||
|
the task board.
|
||||||
|
- After fix 2+3: manually let a goal-bearing session go idle past the
|
||||||
|
threshold (or lower the threshold for a local test run), confirm the
|
||||||
|
nudge appears in the transcript, then confirm second-strike auto-close
|
||||||
|
fires if the nudge is ignored.
|
||||||
|
- Re-run the same audit query used to find this bug
|
||||||
|
(`curl localhost:8092/sessions` → status histogram) a day after deploy;
|
||||||
|
the "stuck active/planning forever" count should track only genuinely
|
||||||
|
in-flight tasks, not accumulate.
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **Outcome for case-1 auto-complete**: always `"success"`, or worth a
|
||||||
|
cheap heuristic (e.g. scan the final text for obvious failure language)?
|
||||||
|
Recommend starting with always-`"success"` — SOUL.md's own trivial-task
|
||||||
|
guidance doesn't distinguish, and a wrong "success" on a genuinely-failed
|
||||||
|
one-shot lookup is low-stakes (the transcript still shows the real
|
||||||
|
answer; nothing acts on the outcome besides the board's color).
|
||||||
|
- **Idle threshold (15 min) and nudge-to-close gap**: arbitrary starting
|
||||||
|
points, not measured against real task durations — worth revisiting after
|
||||||
|
a week of the new sessions' real timing data exists.
|
||||||
|
- **Schema change for nudge tracking**: a new column is simpler to query
|
||||||
|
than packing state into existing JSON, but adds a migration — worth
|
||||||
|
confirming that's acceptable before starting fix 2+3 (this plan defers
|
||||||
|
that call to whoever implements it, per Implementation order above).
|
||||||
262
plans/done/2026-07-11-ui-review-ia-usability.md
Normal file
262
plans/done/2026-07-11-ui-review-ia-usability.md
Normal file
@@ -0,0 +1,262 @@
|
|||||||
|
# UI review: information architecture, usability, and best practices
|
||||||
|
|
||||||
|
Status: Done — 2026-07-11. All fix-plan items implemented and verified live
|
||||||
|
except C2 (a11y lint enforcement — no ESLint/svelte-check is configured in
|
||||||
|
`web/` at all, so there's nothing to promote from warn to error; flagged
|
||||||
|
below instead of silently adding lint infra). Verification also surfaced an
|
||||||
|
unrelated pre-existing bug (Knowledge page search results never render) —
|
||||||
|
spun off as a separate task, not fixed here.
|
||||||
|
|
||||||
|
## Scope
|
||||||
|
|
||||||
|
Systematic review of `web/src/` (Svelte 5 + shadcn-svelte + Tailwind v4
|
||||||
|
control-room UI): all 13 pages, the 11 shared components, the sidebar/routing
|
||||||
|
shell (`App.svelte`), and cross-cutting patterns (filtering, loading/empty
|
||||||
|
states, live-event wiring, accessibility). Read in full, not sampled.
|
||||||
|
Grounded in what's actually in the code — no speculative "best practice"
|
||||||
|
items without a concrete file:line instance.
|
||||||
|
|
||||||
|
Not implementation. Findings and a proposed fix plan only, mirroring
|
||||||
|
[`2026-07-11-nomos-agent-code-review.md`](2026-07-11-nomos-agent-code-review.md)'s
|
||||||
|
structure — implement on a later "proceed."
|
||||||
|
|
||||||
|
## Findings
|
||||||
|
|
||||||
|
### A. Information architecture
|
||||||
|
|
||||||
|
**A1. Entity detail has two competing UI patterns for the same content.**
|
||||||
|
[`Entities.svelte:16-19,155`](../../web/src/pages/Entities.svelte) opens entity
|
||||||
|
detail as an in-page `EntitySheet` slide-over (no URL change, no sidebar
|
||||||
|
state change). [`Knowledge.svelte:57-59`](../../web/src/pages/Knowledge.svelte)
|
||||||
|
and [`Graph.svelte:464`](../../web/src/pages/Graph.svelte) instead navigate via
|
||||||
|
`location.hash = '#/entity/' + slug`, which `App.svelte`'s router resolves to
|
||||||
|
a full-page `EntityDetail` route — but `'entity'` isn't in `navItems`
|
||||||
|
([`App.svelte:68-79`](../../web/src/App.svelte)), so landing there leaves the
|
||||||
|
sidebar with nothing highlighted and the header showing the raw slug instead
|
||||||
|
of a section name. Same underlying view
|
||||||
|
(`EntityDetailContent.svelte`), three different entry points, two
|
||||||
|
different navigation models, one of which produces an orphaned page state.
|
||||||
|
A user who reaches an entity via Knowledge or Graph has no way back to
|
||||||
|
"where they were" via the sidebar — only browser back.
|
||||||
|
|
||||||
|
**A2. Two chat entry points with no visual link between them.**
|
||||||
|
The sidebar's "Tasks" section (board → `Chat.svelte` detail,
|
||||||
|
`isActive={page === 'tasks' || page === 'chat'}`,
|
||||||
|
[`App.svelte:127`](../../web/src/App.svelte)) and the footer's "Chat drawer"
|
||||||
|
button ([`App.svelte:160-163`](../../web/src/App.svelte), opens a `Sheet`
|
||||||
|
wrapping the same `Chat` component) are both valid, intentional ways to
|
||||||
|
reach chat — but nothing in the UI explains they're different modes (drawer
|
||||||
|
= overlay on current page, keeps your place; Tasks = full navigation). A
|
||||||
|
first-time user has no way to know which one preserves their current page.
|
||||||
|
Low-severity, but worth a tooltip/label distinction.
|
||||||
|
|
||||||
|
**A3. Overview's KPI cards don't drill down.**
|
||||||
|
[`Overview.svelte`](../../web/src/pages/Overview.svelte) shows "Pending
|
||||||
|
approvals," "Open signals," and fleet-health counts as static cards. The
|
||||||
|
header badges for the same data (`approvalsPending`, `openSignals`,
|
||||||
|
[`App.svelte:185-194`](../../web/src/App.svelte)) ARE clickable and navigate to
|
||||||
|
Ops/Signals — so the pattern exists in the app, just not on the page whose
|
||||||
|
entire purpose is summarizing this data. A dashboard card showing a count
|
||||||
|
that doesn't lead anywhere is a standard drill-down gap.
|
||||||
|
|
||||||
|
### B. Usability / interaction consistency
|
||||||
|
|
||||||
|
**B1. Table-row click targets lack keyboard/screen-reader support in one
|
||||||
|
place but not others.**
|
||||||
|
[`Entities.svelte:117-120`](../../web/src/pages/Entities.svelte) makes an
|
||||||
|
entire `Table.Row` clickable via a bare `onclick`, with no `role`,
|
||||||
|
`tabindex`, or `onkeydown` — unreachable and inoperable via keyboard, and
|
||||||
|
screen readers get no indication the row is interactive. This is a
|
||||||
|
regression against the codebase's own established pattern: `Tasks.svelte`
|
||||||
|
wraps its cards in real `<button>` elements
|
||||||
|
([`Tasks.svelte:172`](../../web/src/pages/Tasks.svelte)), `Events.svelte`'s
|
||||||
|
correlation-group headers are real `<button>`s
|
||||||
|
([`Events.svelte:110-114`](../../web/src/pages/Events.svelte)), and
|
||||||
|
`Graph.svelte`'s SVG nodes explicitly add `role="button"`, `tabindex="0"`,
|
||||||
|
and `onkeydown` ([`Graph.svelte:416-421`](../../web/src/pages/Graph.svelte)).
|
||||||
|
Entities is the outlier.
|
||||||
|
|
||||||
|
**B2. Filter inputs are inconsistently "live" vs. "apply-on-blur," with no
|
||||||
|
visual cue either way.**
|
||||||
|
`Entities.svelte`'s slug/name filter and `Graph.svelte`'s search box filter
|
||||||
|
as-you-type (bound to a `$derived`). But `Ops.svelte` (implicitly, no text
|
||||||
|
filters), `Audit.svelte`'s action/entity inputs
|
||||||
|
([`Audit.svelte:71-72`](../../web/src/pages/Audit.svelte)),
|
||||||
|
`Agent.svelte`'s agent_id input
|
||||||
|
([`Agent.svelte:59`](../../web/src/pages/Agent.svelte)), and `Events.svelte`'s
|
||||||
|
type/severity inputs ([`Events.svelte:92-93`](../../web/src/pages/Events.svelte))
|
||||||
|
all use `onchange`, which only fires on blur — a user typing a filter value
|
||||||
|
and watching the table sees nothing happen until they click or tab away, and
|
||||||
|
nothing in the UI (placeholder text, a debounce spinner, an "Enter to
|
||||||
|
apply" hint) tells them why. Three different pages share the same
|
||||||
|
`onchange`-only pattern, so it's a systemic choice, not an oversight — but
|
||||||
|
it reads as broken on first use.
|
||||||
|
|
||||||
|
**B3. Entity filter is case-sensitive; nothing else in the app is.**
|
||||||
|
[`Entities.svelte:50`](../../web/src/pages/Entities.svelte) matches with raw
|
||||||
|
`.includes()`, no `.toLowerCase()`. `Graph.svelte`'s equivalent search
|
||||||
|
normalizes both sides
|
||||||
|
([`Graph.svelte:175-176`](../../web/src/pages/Graph.svelte):
|
||||||
|
`n.slug.toLowerCase().includes(q)`). Slugs are lowercase by convention today,
|
||||||
|
which is why this hasn't bitten anyone yet, but entity *names* are
|
||||||
|
free text and can be mixed-case — a name filter that silently returns zero
|
||||||
|
results for a correctly-spelled but wrong-case query is a real trap, and the
|
||||||
|
one-line fix already has a working reference implementation three files
|
||||||
|
away.
|
||||||
|
|
||||||
|
**B4. `{@html}` on server-provided search snippets.**
|
||||||
|
[`Knowledge.svelte:120-121`](../../web/src/pages/Knowledge.svelte) renders
|
||||||
|
`hit.snippet` with `{@html}`, justified by a comment claiming the backend's
|
||||||
|
`ts_headline` output is pre-sanitized. That's true for Postgres
|
||||||
|
`ts_headline` today (it only wraps matched terms in `<b>` from a
|
||||||
|
parameterized query), but there's no client-side enforcement of that
|
||||||
|
invariant — if the search query or snippet source ever changes upstream,
|
||||||
|
this becomes a stored-XSS vector with no guard at the point of use. Not an
|
||||||
|
active vulnerability, but a fragile trust boundary worth tightening
|
||||||
|
defensively (e.g. a tiny allow-list sanitizer) rather than relying on a
|
||||||
|
comment to hold forever.
|
||||||
|
|
||||||
|
### C. Accessibility
|
||||||
|
|
||||||
|
**C1. `SessionRail.svelte`'s delete control is a `<span>`, not a button.**
|
||||||
|
[`SessionRail.svelte:54-64`](../../web/src/lib/components/SessionRail.svelte)
|
||||||
|
attaches `onclick` to a `<span>` for the per-session delete affordance, with
|
||||||
|
no `role`, `tabindex`, or keyboard handler — same defect class as B1, on a
|
||||||
|
destructive action this time (delete a chat session), which makes it a
|
||||||
|
notch more important: a keyboard-only user cannot delete a session from
|
||||||
|
this rail at all.
|
||||||
|
|
||||||
|
**C2. Same defect, lower stakes, elsewhere.**
|
||||||
|
Scan for the same "clickable non-interactive element" shape found in B1/C1
|
||||||
|
should be swept across `web/src/` once — these two are the ones a full read
|
||||||
|
surfaced, but the pattern (a `<div>`/`<span>` with `onclick` and no
|
||||||
|
keyboard path) is exactly the kind of thing that creeps back in per-PR
|
||||||
|
without a lint rule catching it. Worth checking whether
|
||||||
|
`eslint-plugin-svelte`'s `a11y_click_events_have_key_events` /
|
||||||
|
`a11y_no_static_element_interactions` rules are enabled and enforced in CI
|
||||||
|
(the prior summary noted these exist as warnings, not build failures — that
|
||||||
|
should be confirmed and possibly promoted to errors as part of implementing
|
||||||
|
C1/B1).
|
||||||
|
|
||||||
|
### D. Visual / component consistency
|
||||||
|
|
||||||
|
**D1. One page bypasses the shared `Button` component.**
|
||||||
|
`Agent.svelte`'s "Refresh" control is a bare
|
||||||
|
`<button class="rounded-md border px-3 py-1.5 text-xs">`
|
||||||
|
([`Agent.svelte:73`](../../web/src/pages/Agent.svelte)) instead of
|
||||||
|
`Button` (`variant="outline"`), which every other page's refresh/action
|
||||||
|
buttons use (`Ops.svelte`, `Signals.svelte`, `Audit.svelte`, `Events.svelte`
|
||||||
|
all use `<Button variant="outline">`). Cosmetically near-identical today
|
||||||
|
(both render as a bordered pill) but it'll drift the moment the design
|
||||||
|
tokens on `Button` change, since this one doesn't inherit them.
|
||||||
|
|
||||||
|
**D2. `formatEventLabel` is a needless indirection.**
|
||||||
|
[`Overview.svelte`](../../web/src/pages/Overview.svelte)'s
|
||||||
|
`formatEventLabel(ev)` returns `ev.type` verbatim — a one-line wrapper with
|
||||||
|
no formatting logic. Trivial, but noted since it reads as if formatting
|
||||||
|
were intended and never finished.
|
||||||
|
|
||||||
|
### E. Loading / empty states
|
||||||
|
|
||||||
|
No real findings — this is a strength worth naming rather than "fixing."
|
||||||
|
Every page reviewed (Overview, Entities, Ops, Signals, Events, Agent, Audit,
|
||||||
|
Knowledge, Learning, Graph, Tasks) has both a loading state (skeletons or an
|
||||||
|
implicit empty table) and an explicit, page-appropriate empty-state message
|
||||||
|
(not a generic "no data"). That consistency is worth preserving as new pages
|
||||||
|
get added — call it out in the PR template or a short frontend README note
|
||||||
|
rather than leaving it as tribal knowledge.
|
||||||
|
|
||||||
|
## Fix plan
|
||||||
|
|
||||||
|
Priority order, grounded in user impact:
|
||||||
|
|
||||||
|
1. **C1 (SessionRail delete button)** — highest priority: it's a destructive
|
||||||
|
action that's currently unreachable by keyboard at all. Swap the `<span>`
|
||||||
|
for a real `<button>` with `aria-label="Delete session"`, matching the
|
||||||
|
pattern `Tasks.svelte` already uses for its own delete affordance
|
||||||
|
([`Tasks.svelte:195-207`](../../web/src/pages/Tasks.svelte) — same feature,
|
||||||
|
done correctly, in the same codebase).
|
||||||
|
2. **B1 (Entities row click)** — wrap row content in a `<button>` (or add
|
||||||
|
`role="button" tabindex="0" onkeydown`) matching `Tasks.svelte` /
|
||||||
|
`Events.svelte`'s existing pattern.
|
||||||
|
3. **B3 (case-sensitive filter)** — one-line `.toLowerCase()` fix on both
|
||||||
|
sides of the `.includes()` calls in `Entities.svelte:50`.
|
||||||
|
4. **A1 (dual entity-detail navigation)** — pick one pattern. Recommend
|
||||||
|
standardizing on the `EntitySheet` (in-page, no navigation loss) and
|
||||||
|
changing `Knowledge.svelte`/`Graph.svelte`'s "View entity detail" actions
|
||||||
|
to open the sheet directly instead of hash-navigating to the orphaned
|
||||||
|
`#/entity/:slug` route. If the full-page route is kept for deep-linking
|
||||||
|
(a legitimate reason to keep it), then at minimum highlight the
|
||||||
|
originating section in the sidebar and give the header a real label
|
||||||
|
instead of the bare slug.
|
||||||
|
5. **A3 (Overview KPI cards not clickable)** — wrap the approvals/signals
|
||||||
|
cards in the same click-to-navigate pattern already used by the header
|
||||||
|
badges.
|
||||||
|
6. **D1 (Agent.svelte bare button)** — swap for `<Button variant="outline">`.
|
||||||
|
7. **B2 (inconsistent live-vs-blur filtering)** — standardize on
|
||||||
|
`oninput`-driven, debounced (~300ms) filtering across Audit/Agent/Events,
|
||||||
|
matching the already-live feel of Entities/Graph. Lower priority than the
|
||||||
|
above since it's a rough edge, not a defect.
|
||||||
|
8. **B4 (`{@html}` trust boundary)** — add a minimal sanitize step (strip
|
||||||
|
everything but the `<b>` tags `ts_headline` emits) at the point of
|
||||||
|
render, so the safety property doesn't depend on the backend never
|
||||||
|
changing.
|
||||||
|
9. **A2 (chat drawer vs. Tasks unlabeled)** and **D2 (`formatEventLabel`)** —
|
||||||
|
cosmetic, do opportunistically or skip.
|
||||||
|
10. **C2 (a11y lint enforcement)** — checked: `web/` has no ESLint config and
|
||||||
|
no `lint`/`check` npm script at all (confirmed via `package.json` and
|
||||||
|
directory listing). The "a11y warnings" referenced in earlier session
|
||||||
|
notes were editor/IDE diagnostics, not a CI gate. There's nothing to
|
||||||
|
promote from warn to error because no lint infrastructure exists —
|
||||||
|
setting one up is a separate, larger decision (which rules, whether to
|
||||||
|
also add `svelte-check` for types) that wasn't part of this review's
|
||||||
|
scope. Not done; flagging for a separate decision rather than silently
|
||||||
|
bootstrapping tooling.
|
||||||
|
|
||||||
|
## Implementation notes (2026-07-11)
|
||||||
|
|
||||||
|
- C1, B1, B3, A1, A3, D1, D2, B2, B4, A2 all implemented and verified live
|
||||||
|
in the browser preview against the running stack (see Verification below).
|
||||||
|
- A1: standardized on `EntitySheet` per the plan's recommendation —
|
||||||
|
`Knowledge.svelte` and `Graph.svelte`'s "View entity detail" now open the
|
||||||
|
sheet instead of hash-navigating to the orphaned `#/entity/:slug` route.
|
||||||
|
The full-page `EntityDetail` route/component was left in place (not
|
||||||
|
deleted) as a harmless deep-link fallback — nothing internal navigates to
|
||||||
|
it anymore, but a bookmarked/shared URL still resolves.
|
||||||
|
- B4: used the `dompurify` package, already a `dependencies` entry in
|
||||||
|
`web/package.json` (unused until now) — no new dependency added.
|
||||||
|
- B2: added a small `debounce()` helper to `web/src/lib/utils.ts` and
|
||||||
|
switched Audit/Agent/Events' filter inputs from `onchange` (blur-only) to
|
||||||
|
debounced `oninput`.
|
||||||
|
- **Found during verification, not in the original fix list:** the
|
||||||
|
Knowledge page's search never actually renders results (the "Clear"
|
||||||
|
button appears, confirming `searched` flips to `true`, but the content
|
||||||
|
area stays on the "Recently learned" branch) despite the backend request
|
||||||
|
succeeding with real data. Confirmed via `git diff` this isn't caused by
|
||||||
|
anything touched here. Spun off as a separate follow-up rather than fixed
|
||||||
|
in this pass, since it's unrelated to any finding in this review.
|
||||||
|
|
||||||
|
## Verification
|
||||||
|
|
||||||
|
- After each interaction fix (C1, B1, A3): manual keyboard-only pass (Tab +
|
||||||
|
Enter/Space, no mouse) through the affected page in the browser preview.
|
||||||
|
- After B3: type a filter query in Entities with mixed case against a
|
||||||
|
known-mixed-case entity name; confirm it now matches.
|
||||||
|
- After A1: confirm both entry paths (Entities row click, Knowledge search
|
||||||
|
hit's linked entity, Graph node's "View entity detail") land on the same
|
||||||
|
UI pattern; confirm sidebar/header state is coherent from whichever page
|
||||||
|
the user started on.
|
||||||
|
- `cd web && npm run lint && npm run check` clean after all fixes.
|
||||||
|
- Visual: `npm run build` + spot-check each changed page in the browser
|
||||||
|
preview (light pass, not full regression).
|
||||||
|
|
||||||
|
## Open questions
|
||||||
|
|
||||||
|
- **A1's resolution direction** (sheet vs. full-page route) is a genuine
|
||||||
|
product call, not just a bug fix — needs a decision before implementing,
|
||||||
|
not just "proceed." Recommendation given above (standardize on the
|
||||||
|
sheet), but flagging it explicitly since it changes user-visible behavior
|
||||||
|
for Knowledge and Graph, not just Entities.
|
||||||
|
- Whether to promote a11y lint rules from warn to error (C2) is a policy
|
||||||
|
call for the repo, worth a one-line "yes/no" rather than silently doing
|
||||||
|
it.
|
||||||
@@ -8,15 +8,12 @@ went sideways, open an investigation.
|
|||||||
|
|
||||||
| Date | Title | Status |
|
| Date | Title | Status |
|
||||||
| ---- | ----- | ------ |
|
| ---- | ----- | ------ |
|
||||||
| 2026-07-05 | [Oikos Prometheus LXC](2026-07-05-oikos-prometheus-lxc.md) | Planned |
|
| 2026-07-05 | [Oikos Prometheus LXC](2026-07-05-oikos-prometheus-lxc.md) | Planned — not started |
|
||||||
| 2026-07-08 | [Plan vs implementation cross-reference](2026-07-08-plan-implementation-audit.md) | Planned |
|
| 2026-07-08 | [Oikos gaps, broken things, and improvements](2026-07-08-oikos-gaps-and-improvements.md) | In Progress — security items (B1-B5) and doc drift (E) still open |
|
||||||
| 2026-07-08 | [Oikos gaps, broken things, and improvements](2026-07-08-oikos-gaps-and-improvements.md) | Planned |
|
|
||||||
| 2026-07-08 | [Control room web UI](2026-07-08-control-room-webui.md) | In Progress |
|
| 2026-07-08 | [Control room web UI](2026-07-08-control-room-webui.md) | In Progress |
|
||||||
| 2026-07-08 | [Nomos resident agent (renames Hermes)](2026-07-08-nomos-resident-agent.md) | In Progress |
|
| 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress — Phase 5 deferred |
|
||||||
| 2026-07-08 | [Liveness, drift, and UX cohesion](2026-07-08-liveness-drift-and-ux-cohesion.md) | In Progress |
|
| 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | In Progress — enum retirement + auto-act revival still open |
|
||||||
| 2026-07-09 | [Chat sessions: reliability, cost, and session-management fixes](2026-07-09-chat-sessions-improvements.md) | Planned |
|
| 2026-07-11 | [Nomos agent code review: gaps and improvement plan](2026-07-11-nomos-agent-code-review.md) | In Progress — only C1 (unauthenticated nomos gateway) still open, deferred |
|
||||||
| 2026-07-09 | [Session execution, UX, and learning improvements](2026-07-09-session-execution-and-ux-fixes.md) | Planned |
|
|
||||||
| 2026-07-10 | [General gated execution: unlimited actions, gated by risk](2026-07-10-general-gated-execution.md) | Planned |
|
|
||||||
|
|
||||||
## Done
|
## Done
|
||||||
|
|
||||||
@@ -37,6 +34,15 @@ See [`done/`](done/) for executed plans:
|
|||||||
| 2026-07-08 | [Fix MCP analysis tools](done/2026-07-08-fix-mcp-analysis-tools.md) |
|
| 2026-07-08 | [Fix MCP analysis tools](done/2026-07-08-fix-mcp-analysis-tools.md) |
|
||||||
| 2026-07-06 | [Consolidate Oikos control plane onto mac-mini](done/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md) |
|
| 2026-07-06 | [Consolidate Oikos control plane onto mac-mini](done/2026-07-06-consolidate-oikos-control-plane-onto-mac-mini.md) |
|
||||||
| 2026-07-08 | [Signal triggers: host health checks](done/2026-07-08-signal-triggers.md) |
|
| 2026-07-08 | [Signal triggers: host health checks](done/2026-07-08-signal-triggers.md) |
|
||||||
|
| 2026-07-08 | [Plan vs implementation cross-reference](done/2026-07-08-plan-implementation-audit.md) |
|
||||||
|
| 2026-07-08 | [Nomos resident agent (renames Hermes)](done/2026-07-08-nomos-resident-agent.md) |
|
||||||
|
| 2026-07-09 | [Chat sessions: reliability, cost, and session-management fixes](done/2026-07-09-chat-sessions-improvements.md) |
|
||||||
|
| 2026-07-09 | [Session execution, UX, and learning improvements](done/2026-07-09-session-execution-and-ux-fixes.md) |
|
||||||
|
| 2026-07-10 | [Autonomous plan execution: close the observation gap](done/2026-07-10-autonomous-plan-execution.md) |
|
||||||
|
| 2026-07-11 | [Tasks: the chat page as goal-structured autonomous work](done/2026-07-11-goal-oriented-chat-control-panel.md) |
|
||||||
|
| 2026-07-11 | [Concurrent task execution: safety + throughput + frontend correctness](done/2026-07-11-concurrent-task-execution.md) |
|
||||||
|
| 2026-07-11 | [UI review: information architecture, usability, and best practices](done/2026-07-11-ui-review-ia-usability.md) |
|
||||||
|
| 2026-07-11 | [Task completion safety net: every live task is stuck "Running"](done/2026-07-11-task-completion-safety-net.md) |
|
||||||
|
|
||||||
## Conventions
|
## Conventions
|
||||||
|
|
||||||
|
|||||||
@@ -641,6 +641,14 @@ entity_types:
|
|||||||
domain: cognition
|
domain: cognition
|
||||||
layer: cognition
|
layer: cognition
|
||||||
description: Recorded investigation/postmortem.
|
description: Recorded investigation/postmortem.
|
||||||
|
task:
|
||||||
|
parent: entity
|
||||||
|
domain: cognition
|
||||||
|
layer: cognition
|
||||||
|
description: A goal-structured unit of agent work — one chat/session elevated
|
||||||
|
to a task with a plan, lifecycle status, and outcome. Anchors the knowledge
|
||||||
|
and involved-entity relationships for the task so future tasks can learn
|
||||||
|
from it. Typed rows in agent_sessions.
|
||||||
|
|
||||||
# ─── Relationship types ────────────────────────────────────────────────
|
# ─── Relationship types ────────────────────────────────────────────────
|
||||||
# cardinality is source→target: e.g. `hosts` one-to-many = one machine
|
# cardinality is source→target: e.g. `hosts` one-to-many = one machine
|
||||||
@@ -928,6 +936,14 @@ relationship_types:
|
|||||||
target: entity
|
target: entity
|
||||||
cardinality: many-to-one
|
cardinality: many-to-one
|
||||||
description: Document describes an entity.
|
description: Document describes an entity.
|
||||||
|
involves:
|
||||||
|
inverse: involved-in
|
||||||
|
source: task
|
||||||
|
target: entity
|
||||||
|
cardinality: many-to-many
|
||||||
|
description: Task explored or acted on an entity (captured from its tool
|
||||||
|
calls). A task's involved-entity set is its graph neighborhood, so future
|
||||||
|
tasks on the same entities can surface this task's knowledge and outcome.
|
||||||
procedure-for:
|
procedure-for:
|
||||||
inverse: has-procedure
|
inverse: has-procedure
|
||||||
source: runbook
|
source: runbook
|
||||||
|
|||||||
@@ -1,17 +1,14 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import Chat from './pages/Chat.svelte'
|
import Chat from './pages/Chat.svelte'
|
||||||
import Sessions from './pages/Sessions.svelte'
|
import Tasks from './pages/Tasks.svelte'
|
||||||
import Overview from './pages/Overview.svelte'
|
import Overview from './pages/Overview.svelte'
|
||||||
import Entities from './pages/Entities.svelte'
|
import Entities from './pages/Entities.svelte'
|
||||||
import Events from './pages/Events.svelte'
|
|
||||||
import Ops from './pages/Ops.svelte'
|
import Ops from './pages/Ops.svelte'
|
||||||
import Signals from './pages/Signals.svelte'
|
import Signals from './pages/Signals.svelte'
|
||||||
import Graph from './pages/Graph.svelte'
|
import Graph from './pages/Graph.svelte'
|
||||||
import EntityDetail from './pages/EntityDetail.svelte'
|
import EntityDetail from './pages/EntityDetail.svelte'
|
||||||
import Agent from './pages/Agent.svelte'
|
|
||||||
import Knowledge from './pages/Knowledge.svelte'
|
import Knowledge from './pages/Knowledge.svelte'
|
||||||
import Learning from './pages/Learning.svelte'
|
import Learning from './pages/Learning.svelte'
|
||||||
import Audit from './pages/Audit.svelte'
|
|
||||||
import { newChat } from '$lib/stores/chat'
|
import { newChat } from '$lib/stores/chat'
|
||||||
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
|
import { summary, subscribeContext, openSignalCount } from '$lib/stores/context'
|
||||||
import { connectionState } from '$lib/stores/events'
|
import { connectionState } from '$lib/stores/events'
|
||||||
@@ -23,20 +20,18 @@
|
|||||||
import { Separator } from '$lib/components/ui/separator'
|
import { Separator } from '$lib/components/ui/separator'
|
||||||
import { Toaster } from '$lib/components/ui/sonner'
|
import { Toaster } from '$lib/components/ui/sonner'
|
||||||
import PlusIcon from '@lucide/svelte/icons/plus'
|
import PlusIcon from '@lucide/svelte/icons/plus'
|
||||||
|
import ListTodoIcon from '@lucide/svelte/icons/list-todo'
|
||||||
import MessageSquareIcon from '@lucide/svelte/icons/message-square'
|
import MessageSquareIcon from '@lucide/svelte/icons/message-square'
|
||||||
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'
|
import LayoutDashboardIcon from '@lucide/svelte/icons/layout-dashboard'
|
||||||
import DatabaseIcon from '@lucide/svelte/icons/database'
|
import DatabaseIcon from '@lucide/svelte/icons/database'
|
||||||
import ActivityIcon from '@lucide/svelte/icons/activity'
|
|
||||||
import PanelRightIcon from '@lucide/svelte/icons/panel-right'
|
import PanelRightIcon from '@lucide/svelte/icons/panel-right'
|
||||||
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
import ShieldCheckIcon from '@lucide/svelte/icons/shield-check'
|
||||||
import SirenIcon from '@lucide/svelte/icons/siren'
|
import SirenIcon from '@lucide/svelte/icons/siren'
|
||||||
import NetworkIcon from '@lucide/svelte/icons/share-2'
|
import NetworkIcon from '@lucide/svelte/icons/share-2'
|
||||||
import BotIcon from '@lucide/svelte/icons/bot'
|
|
||||||
import SearchIcon from '@lucide/svelte/icons/search'
|
import SearchIcon from '@lucide/svelte/icons/search'
|
||||||
import ScrollTextIcon from '@lucide/svelte/icons/scroll-text'
|
|
||||||
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
|
import TrendingUpIcon from '@lucide/svelte/icons/trending-up'
|
||||||
|
|
||||||
let page = $state('chat')
|
let page = $state('tasks')
|
||||||
let routeParam = $state('')
|
let routeParam = $state('')
|
||||||
let drawerOpen = $state(false)
|
let drawerOpen = $state(false)
|
||||||
|
|
||||||
@@ -45,9 +40,9 @@
|
|||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
function sync() {
|
function sync() {
|
||||||
const path = location.hash.slice(2) || 'chat'
|
const path = location.hash.slice(2) || 'tasks'
|
||||||
const [head, ...rest] = path.split('/')
|
const [head, ...rest] = path.split('/')
|
||||||
page = head || 'chat'
|
page = head || 'tasks'
|
||||||
routeParam = rest.join('/')
|
routeParam = rest.join('/')
|
||||||
}
|
}
|
||||||
sync()
|
sync()
|
||||||
@@ -70,11 +65,8 @@
|
|||||||
{ id: 'graph', label: 'Graph', icon: NetworkIcon },
|
{ id: 'graph', label: 'Graph', icon: NetworkIcon },
|
||||||
{ id: 'ops', label: 'Operations', icon: ShieldCheckIcon, badge: () => approvalsPending },
|
{ id: 'ops', label: 'Operations', icon: ShieldCheckIcon, badge: () => approvalsPending },
|
||||||
{ id: 'signals', label: 'Signals', icon: SirenIcon, badge: () => openSignals },
|
{ id: 'signals', label: 'Signals', icon: SirenIcon, badge: () => openSignals },
|
||||||
{ id: 'events', label: 'Events', icon: ActivityIcon },
|
|
||||||
{ id: 'agent', label: 'Agent', icon: BotIcon },
|
|
||||||
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
|
{ id: 'knowledge', label: 'Knowledge', icon: SearchIcon },
|
||||||
{ id: 'learning', label: 'Learning', icon: TrendingUpIcon },
|
{ id: 'learning', label: 'Learning', icon: TrendingUpIcon }
|
||||||
{ id: 'audit', label: 'Audit', icon: ScrollTextIcon }
|
|
||||||
]
|
]
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -106,12 +98,12 @@
|
|||||||
<Sidebar.MenuButton
|
<Sidebar.MenuButton
|
||||||
class="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
|
class="bg-primary text-primary-foreground hover:bg-primary/90 hover:text-primary-foreground active:bg-primary/90 active:text-primary-foreground min-w-8 duration-200 ease-linear"
|
||||||
onclick={() => { newChat(); navigate('chat') }}
|
onclick={() => { newChat(); navigate('chat') }}
|
||||||
tooltipContent="New chat"
|
tooltipContent="New task"
|
||||||
>
|
>
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<button {...props}>
|
<button {...props}>
|
||||||
<PlusIcon />
|
<PlusIcon />
|
||||||
<span>New chat</span>
|
<span>New task</span>
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
@@ -123,11 +115,11 @@
|
|||||||
<Sidebar.Group>
|
<Sidebar.Group>
|
||||||
<Sidebar.Menu>
|
<Sidebar.Menu>
|
||||||
<Sidebar.MenuItem>
|
<Sidebar.MenuItem>
|
||||||
<Sidebar.MenuButton isActive={page === 'chat'} onclick={() => navigate('chat')} tooltipContent="Chat">
|
<Sidebar.MenuButton isActive={page === 'tasks' || page === 'chat'} onclick={() => navigate('tasks')} tooltipContent="Tasks">
|
||||||
{#snippet child({ props })}
|
{#snippet child({ props })}
|
||||||
<button {...props}>
|
<button {...props}>
|
||||||
<MessageSquareIcon />
|
<ListTodoIcon />
|
||||||
<span>Chat</span>
|
<span>Tasks</span>
|
||||||
</button>
|
</button>
|
||||||
{/snippet}
|
{/snippet}
|
||||||
</Sidebar.MenuButton>
|
</Sidebar.MenuButton>
|
||||||
@@ -156,7 +148,13 @@
|
|||||||
</Sidebar.Content>
|
</Sidebar.Content>
|
||||||
|
|
||||||
<Sidebar.Footer>
|
<Sidebar.Footer>
|
||||||
<Button variant="ghost" size="sm" class="justify-start gap-2" onclick={() => (drawerOpen = true)}>
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="sm"
|
||||||
|
class="justify-start gap-2"
|
||||||
|
onclick={() => (drawerOpen = true)}
|
||||||
|
title="Chat over the current page without navigating away"
|
||||||
|
>
|
||||||
<PanelRightIcon />
|
<PanelRightIcon />
|
||||||
<span>Chat drawer</span>
|
<span>Chat drawer</span>
|
||||||
</Button>
|
</Button>
|
||||||
@@ -167,7 +165,13 @@
|
|||||||
<header class="flex h-(--header-height) shrink-0 items-center gap-1 border-b px-4 lg:gap-2 lg:px-6">
|
<header class="flex h-(--header-height) shrink-0 items-center gap-1 border-b px-4 lg:gap-2 lg:px-6">
|
||||||
<Sidebar.Trigger class="-ms-1" />
|
<Sidebar.Trigger class="-ms-1" />
|
||||||
<Separator orientation="vertical" class="mx-2 data-[orientation=vertical]:h-4" />
|
<Separator orientation="vertical" class="mx-2 data-[orientation=vertical]:h-4" />
|
||||||
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page}</span>
|
{#if page === 'chat'}
|
||||||
|
<button type="button" class="text-sm text-muted-foreground hover:text-foreground" onclick={() => navigate('tasks')}>Tasks</button>
|
||||||
|
<span class="text-muted-foreground">/</span>
|
||||||
|
<span class="text-base font-medium">Conversation</span>
|
||||||
|
{:else}
|
||||||
|
<span class="text-base font-medium capitalize">{page === 'entity' ? routeParam : page}</span>
|
||||||
|
{/if}
|
||||||
<div class="ms-auto flex items-center gap-2.5">
|
<div class="ms-auto flex items-center gap-2.5">
|
||||||
{#if $summary}
|
{#if $summary}
|
||||||
<div class="hidden items-center gap-2.5 text-xs text-muted-foreground sm:flex">
|
<div class="hidden items-center gap-2.5 text-xs text-muted-foreground sm:flex">
|
||||||
@@ -195,6 +199,8 @@
|
|||||||
<main class="min-h-0 flex-1 overflow-hidden">
|
<main class="min-h-0 flex-1 overflow-hidden">
|
||||||
{#if page === 'overview'}
|
{#if page === 'overview'}
|
||||||
<Overview />
|
<Overview />
|
||||||
|
{:else if page === 'tasks'}
|
||||||
|
<Tasks />
|
||||||
{:else if page === 'entities'}
|
{:else if page === 'entities'}
|
||||||
<Entities />
|
<Entities />
|
||||||
{:else if page === 'graph'}
|
{:else if page === 'graph'}
|
||||||
@@ -205,18 +211,10 @@
|
|||||||
<Ops />
|
<Ops />
|
||||||
{:else if page === 'signals'}
|
{:else if page === 'signals'}
|
||||||
<Signals />
|
<Signals />
|
||||||
{:else if page === 'events'}
|
|
||||||
<Events />
|
|
||||||
{:else if page === 'sessions'}
|
|
||||||
<Sessions />
|
|
||||||
{:else if page === 'agent'}
|
|
||||||
<Agent />
|
|
||||||
{:else if page === 'knowledge'}
|
{:else if page === 'knowledge'}
|
||||||
<Knowledge />
|
<Knowledge />
|
||||||
{:else if page === 'learning'}
|
{:else if page === 'learning'}
|
||||||
<Learning />
|
<Learning />
|
||||||
{:else if page === 'audit'}
|
|
||||||
<Audit />
|
|
||||||
{:else}
|
{:else}
|
||||||
<Chat />
|
<Chat />
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -1,10 +1,18 @@
|
|||||||
const BASE = '/agent'
|
const BASE = '/agent'
|
||||||
const API = '/api/v1'
|
const API = '/api/v1'
|
||||||
|
|
||||||
|
// A session IS a task: a goal-structured unit of work with a lifecycle status
|
||||||
|
// and an outcome. goal/outcome/summary/entity_id are empty until the agent sets
|
||||||
|
// them (see the task-board plan). status defaults to 'active'.
|
||||||
export interface Session {
|
export interface Session {
|
||||||
id: string
|
id: string
|
||||||
title: string
|
title: string
|
||||||
actor: string
|
actor: string
|
||||||
|
goal?: string
|
||||||
|
status?: string // active | planning | executing | awaiting_input | done | failed | abandoned
|
||||||
|
outcome?: string // success | failure | partial
|
||||||
|
summary?: string
|
||||||
|
entity_id?: string
|
||||||
created_at: string
|
created_at: string
|
||||||
last_active_at: string
|
last_active_at: string
|
||||||
}
|
}
|
||||||
@@ -36,6 +44,51 @@ export async function deleteSession(sessionId: string): Promise<boolean> {
|
|||||||
return res.ok
|
return res.ok
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface PlanStep {
|
||||||
|
id: string
|
||||||
|
seq: number
|
||||||
|
title: string
|
||||||
|
detail: string
|
||||||
|
status: 'pending' | 'running' | 'done' | 'failed' | 'skipped' | 'blocked'
|
||||||
|
execution_id?: string
|
||||||
|
target_slug?: string
|
||||||
|
started_at?: string
|
||||||
|
finished_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPlan(sessionId: string): Promise<PlanStep[]> {
|
||||||
|
const res = await fetch(`${BASE}/sessions/${sessionId}/plan`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.steps ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SessionQuestion {
|
||||||
|
id: string
|
||||||
|
prompt: string
|
||||||
|
context: { why?: string; options?: string[]; entities?: string[] }
|
||||||
|
status: 'open' | 'answered' | 'dismissed'
|
||||||
|
answer?: string
|
||||||
|
created_at: string
|
||||||
|
answered_at?: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchQuestions(sessionId: string): Promise<SessionQuestion[]> {
|
||||||
|
const res = await fetch(`${BASE}/sessions/${sessionId}/questions`)
|
||||||
|
if (!res.ok) return []
|
||||||
|
const data = await res.json()
|
||||||
|
return data.questions ?? []
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function answerQuestion(sessionId: string, questionId: string, answer: string): Promise<boolean> {
|
||||||
|
const res = await fetch(`${BASE}/sessions/${sessionId}/questions/${questionId}/answer`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ answer })
|
||||||
|
})
|
||||||
|
return res.ok
|
||||||
|
}
|
||||||
|
|
||||||
export interface ChatEvent {
|
export interface ChatEvent {
|
||||||
type: string
|
type: string
|
||||||
data: any
|
data: any
|
||||||
|
|||||||
@@ -11,19 +11,27 @@
|
|||||||
fetchEntityExecutions,
|
fetchEntityExecutions,
|
||||||
fetchEntityKnowledge,
|
fetchEntityKnowledge,
|
||||||
fetchChecksForTarget,
|
fetchChecksForTarget,
|
||||||
|
fetchAgentActivity,
|
||||||
|
fetchAudit,
|
||||||
patchCheck,
|
patchCheck,
|
||||||
|
ackSignal,
|
||||||
|
resolveSignal,
|
||||||
|
muteSignal,
|
||||||
type Entity,
|
type Entity,
|
||||||
type Relationship,
|
type Relationship,
|
||||||
type MetricSeries,
|
type MetricSeries,
|
||||||
type Signal,
|
type Signal,
|
||||||
type Execution,
|
type Execution,
|
||||||
type KnowledgeHit,
|
type KnowledgeHit,
|
||||||
type Check
|
type Check,
|
||||||
|
type AgentActivity,
|
||||||
|
type AuditEntry
|
||||||
} from '$lib/api'
|
} from '$lib/api'
|
||||||
import { relativeTime } from '$lib/utils'
|
import { relativeTime } from '$lib/utils'
|
||||||
import type { OikosEvent } from '$lib/stores/events'
|
import type { OikosEvent } from '$lib/stores/events'
|
||||||
import * as Card from '$lib/components/ui/card'
|
import * as Card from '$lib/components/ui/card'
|
||||||
import { Badge } from '$lib/components/ui/badge'
|
import { Badge } from '$lib/components/ui/badge'
|
||||||
|
import { Button } from '$lib/components/ui/button'
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||||
import { toast } from 'svelte-sonner'
|
import { toast } from 'svelte-sonner'
|
||||||
|
|
||||||
@@ -37,7 +45,10 @@
|
|||||||
let executions = $state<Execution[]>([])
|
let executions = $state<Execution[]>([])
|
||||||
let knowledge = $state<KnowledgeHit[]>([])
|
let knowledge = $state<KnowledgeHit[]>([])
|
||||||
let checks = $state<Check[]>([])
|
let checks = $state<Check[]>([])
|
||||||
|
let agentActivity = $state<AgentActivity[]>([])
|
||||||
|
let auditEntries = $state<AuditEntry[]>([])
|
||||||
let loading = $state(true)
|
let loading = $state(true)
|
||||||
|
let actingSignal = $state<string | null>(null)
|
||||||
let chartContainers: Record<string, HTMLDivElement> = {}
|
let chartContainers: Record<string, HTMLDivElement> = {}
|
||||||
|
|
||||||
async function load(s: string) {
|
async function load(s: string) {
|
||||||
@@ -47,14 +58,16 @@
|
|||||||
loading = false
|
loading = false
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
const [graphView, m, ev, sig, exec, kh, ch] = await Promise.all([
|
const [graphView, m, ev, sig, exec, kh, ch, aa, au] = await Promise.all([
|
||||||
fetchGraph({ root: entity.id, depth: 1 }),
|
fetchGraph({ root: entity.id, depth: 1 }),
|
||||||
fetchMetrics(entity.id),
|
fetchMetrics(entity.id),
|
||||||
fetchEntityEvents(entity.id),
|
fetchEntityEvents(entity.id),
|
||||||
fetchEntitySignals(entity.id),
|
fetchEntitySignals(entity.id),
|
||||||
fetchEntityExecutions(entity.id),
|
fetchEntityExecutions(entity.id),
|
||||||
fetchEntityKnowledge(entity.id),
|
fetchEntityKnowledge(entity.id),
|
||||||
fetchChecksForTarget(entity.slug)
|
fetchChecksForTarget(entity.slug),
|
||||||
|
fetchAgentActivity({ entity_id: entity.id, limit: 50 }),
|
||||||
|
fetchAudit({ entity_id: entity.id, limit: 50 })
|
||||||
])
|
])
|
||||||
relations = graphView?.edges ?? []
|
relations = graphView?.edges ?? []
|
||||||
metrics = m
|
metrics = m
|
||||||
@@ -63,12 +76,51 @@
|
|||||||
executions = exec
|
executions = exec
|
||||||
knowledge = kh
|
knowledge = kh
|
||||||
checks = ch
|
checks = ch
|
||||||
|
agentActivity = aa
|
||||||
|
auditEntries = au
|
||||||
loading = false
|
loading = false
|
||||||
|
|
||||||
await tick()
|
await tick()
|
||||||
renderCharts()
|
renderCharts()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function ackOpenSignal(id: string) {
|
||||||
|
actingSignal = id
|
||||||
|
const result = await ackSignal(id)
|
||||||
|
actingSignal = null
|
||||||
|
if (result) {
|
||||||
|
toast.success('Signal acknowledged')
|
||||||
|
signals = signals.map((s) => (s.id === id ? result : s))
|
||||||
|
} else {
|
||||||
|
toast.error('Acknowledge failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function resolveOpenSignal(id: string) {
|
||||||
|
actingSignal = id
|
||||||
|
const result = await resolveSignal(id)
|
||||||
|
actingSignal = null
|
||||||
|
if (result) {
|
||||||
|
toast.success('Signal resolved')
|
||||||
|
signals = signals.map((s) => (s.id === id ? result : s))
|
||||||
|
} else {
|
||||||
|
toast.error('Resolve failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function muteOpenSignal(id: string) {
|
||||||
|
actingSignal = id
|
||||||
|
const muteUntil = new Date(Date.now() + 60 * 60 * 1000).toISOString()
|
||||||
|
const result = await muteSignal(id, muteUntil)
|
||||||
|
actingSignal = null
|
||||||
|
if (result) {
|
||||||
|
toast.success('Signal muted for 1h')
|
||||||
|
signals = signals.map((s) => (s.id === id ? result : s))
|
||||||
|
} else {
|
||||||
|
toast.error('Mute failed')
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
load(slug)
|
load(slug)
|
||||||
})
|
})
|
||||||
@@ -232,13 +284,44 @@
|
|||||||
<div class="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
|
<div class="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
|
||||||
<Card.Root>
|
<Card.Root>
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<Card.Title class="text-sm">Open signals</Card.Title>
|
<Card.Title class="text-sm">Signals</Card.Title>
|
||||||
</Card.Header>
|
</Card.Header>
|
||||||
<Card.Content class="flex flex-col gap-1">
|
<Card.Content class="flex flex-col gap-2">
|
||||||
{#each signals as signal (signal.id)}
|
{#each signals as signal (signal.id)}
|
||||||
<div class="flex items-center justify-between text-xs">
|
<div class="flex flex-col gap-1 border-b pb-2 text-xs last:border-0 last:pb-0">
|
||||||
<span>{signal.kind}</span>
|
<div class="flex items-center justify-between gap-2">
|
||||||
<Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge>
|
<span>{signal.kind}</span>
|
||||||
|
<div class="flex items-center gap-1">
|
||||||
|
<Badge variant={severityVariant(signal.severity)}>{signal.severity}</Badge>
|
||||||
|
<Badge variant="outline">{signal.state}</Badge>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{#if ['raised', 'acknowledged', 'acting'].includes(signal.state)}
|
||||||
|
<div class="flex justify-end gap-1.5">
|
||||||
|
{#if signal.state === 'raised'}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
class="h-6 px-2 text-xs"
|
||||||
|
disabled={actingSignal === signal.id}
|
||||||
|
onclick={() => ackOpenSignal(signal.id)}>Ack</Button
|
||||||
|
>
|
||||||
|
{/if}
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="outline"
|
||||||
|
class="h-6 px-2 text-xs"
|
||||||
|
disabled={actingSignal === signal.id}
|
||||||
|
onclick={() => muteOpenSignal(signal.id)}>Mute 1h</Button
|
||||||
|
>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
class="h-6 px-2 text-xs"
|
||||||
|
disabled={actingSignal === signal.id}
|
||||||
|
onclick={() => resolveOpenSignal(signal.id)}>Resolve</Button
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="text-xs text-muted-foreground">None.</p>
|
<p class="text-xs text-muted-foreground">None.</p>
|
||||||
@@ -278,20 +361,62 @@
|
|||||||
</Card.Root>
|
</Card.Root>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Card.Root>
|
<div class="grid grid-cols-1 gap-4 @3xl:grid-cols-3">
|
||||||
<Card.Header>
|
<Card.Root>
|
||||||
<Card.Title class="text-sm">Recent events</Card.Title>
|
<Card.Header>
|
||||||
</Card.Header>
|
<Card.Title class="text-sm">Recent events</Card.Title>
|
||||||
<Card.Content class="flex flex-col gap-1">
|
</Card.Header>
|
||||||
{#each events as ev (ev.id)}
|
<Card.Content class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
|
||||||
<div class="flex items-center gap-2 text-xs">
|
{#each events as ev (ev.id)}
|
||||||
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
|
<div class="flex items-center justify-between gap-2 text-xs">
|
||||||
<span>{ev.type}</span>
|
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleString()}</span>
|
||||||
</div>
|
<span class="truncate">{ev.type}</span>
|
||||||
{:else}
|
</div>
|
||||||
<p class="text-xs text-muted-foreground">No events yet.</p>
|
{:else}
|
||||||
{/each}
|
<p class="text-xs text-muted-foreground">No events yet.</p>
|
||||||
</Card.Content>
|
{/each}
|
||||||
</Card.Root>
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="text-sm">Agent activity</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
|
||||||
|
{#each agentActivity as activity (activity.id)}
|
||||||
|
<div class="flex flex-col gap-0.5 border-b pb-1.5 text-xs last:border-0 last:pb-0">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-mono text-muted-foreground">{new Date(activity.ts).toLocaleString()}</span>
|
||||||
|
<Badge variant={activity.success === false ? 'destructive' : 'outline'}>{activity.activity_type}</Badge>
|
||||||
|
</div>
|
||||||
|
<span class="truncate text-muted-foreground"
|
||||||
|
>{activity.agent_id}{activity.tool_name ? ` · ${activity.tool_name}` : ''}</span
|
||||||
|
>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">No agent activity.</p>
|
||||||
|
{/each}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
|
||||||
|
<Card.Root>
|
||||||
|
<Card.Header>
|
||||||
|
<Card.Title class="text-sm">Audit trail</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="flex max-h-72 flex-col gap-1.5 overflow-y-auto">
|
||||||
|
{#each auditEntries as entry (entry.id)}
|
||||||
|
<div class="flex flex-col gap-0.5 border-b pb-1.5 text-xs last:border-0 last:pb-0">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="font-mono text-muted-foreground">{new Date(entry.ts).toLocaleString()}</span>
|
||||||
|
<Badge variant="outline">{entry.actor_type}</Badge>
|
||||||
|
</div>
|
||||||
|
<span class="truncate text-muted-foreground">{entry.actor_id ?? '—'} · {entry.action}</span>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs text-muted-foreground">No audit entries.</p>
|
||||||
|
{/each}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
38
web/src/lib/components/GoalHeader.svelte
Normal file
38
web/src/lib/components/GoalHeader.svelte
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { currentTask } from '$lib/stores/workspace'
|
||||||
|
import { Badge } from '$lib/components/ui/badge'
|
||||||
|
|
||||||
|
type StatusStyle = { label: string; dot: string; pulse: boolean; variant: 'default' | 'secondary' | 'destructive' | 'outline' }
|
||||||
|
|
||||||
|
function statusStyle(status: string | undefined, outcome: string | undefined): StatusStyle {
|
||||||
|
switch (status) {
|
||||||
|
case 'awaiting_input':
|
||||||
|
return { label: 'Needs your input', dot: 'bg-warning', pulse: true, variant: 'secondary' }
|
||||||
|
case 'done':
|
||||||
|
return outcome === 'partial'
|
||||||
|
? { label: 'Done · partial', dot: 'bg-warning', pulse: false, variant: 'secondary' }
|
||||||
|
: { label: 'Done', dot: 'bg-success', pulse: false, variant: 'default' }
|
||||||
|
case 'failed':
|
||||||
|
return { label: 'Failed', dot: 'bg-destructive', pulse: false, variant: 'destructive' }
|
||||||
|
case 'planning':
|
||||||
|
return { label: 'Planning', dot: 'bg-primary', pulse: true, variant: 'secondary' }
|
||||||
|
case 'executing':
|
||||||
|
return { label: 'Executing', dot: 'bg-primary', pulse: true, variant: 'secondary' }
|
||||||
|
default:
|
||||||
|
return { label: 'Active', dot: 'bg-muted-foreground', pulse: false, variant: 'outline' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $currentTask}
|
||||||
|
{@const st = statusStyle($currentTask.status, $currentTask.outcome)}
|
||||||
|
<div class="flex shrink-0 flex-col gap-1.5 border-b px-3 py-2.5">
|
||||||
|
<div class="flex items-center gap-1.5">
|
||||||
|
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
|
||||||
|
<Badge variant={st.variant} class="text-[10px]">{st.label}</Badge>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm font-medium leading-snug">
|
||||||
|
{$currentTask.goal || $currentTask.title || 'Untitled task'}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
76
web/src/lib/components/OperatorQuestion.svelte
Normal file
76
web/src/lib/components/OperatorQuestion.svelte
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { openQuestion } from '$lib/stores/workspace'
|
||||||
|
import { currentSession } from '$lib/stores/chat'
|
||||||
|
import { answerQuestion as postAnswer } from '$lib/api'
|
||||||
|
import { Button } from '$lib/components/ui/button'
|
||||||
|
import { Textarea } from '$lib/components/ui/textarea'
|
||||||
|
import CircleHelpIcon from '@lucide/svelte/icons/circle-help'
|
||||||
|
|
||||||
|
let freeText = $state('')
|
||||||
|
let submitting = $state(false)
|
||||||
|
|
||||||
|
async function submit(answer: string) {
|
||||||
|
const sid = $currentSession
|
||||||
|
const q = $openQuestion
|
||||||
|
if (!sid || !q || !answer.trim() || submitting) return
|
||||||
|
submitting = true
|
||||||
|
const ok = await postAnswer(sid, q.id, answer.trim())
|
||||||
|
submitting = false
|
||||||
|
if (ok) freeText = ''
|
||||||
|
// No local optimistic clear: the question.answered event (which the POST
|
||||||
|
// triggers server-side) updates the store — this stays truthful if the
|
||||||
|
// POST reports ok but the event is somehow delayed.
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if $openQuestion}
|
||||||
|
{@const q = $openQuestion}
|
||||||
|
<div class="flex shrink-0 flex-col gap-2 border-b bg-warning/5 px-3 py-2.5">
|
||||||
|
<div class="flex items-start gap-2">
|
||||||
|
<CircleHelpIcon class="mt-0.5 size-4 shrink-0 text-warning" />
|
||||||
|
<div class="min-w-0 flex-1">
|
||||||
|
<p class="text-sm font-medium leading-snug">{q.prompt}</p>
|
||||||
|
{#if q.context.why}
|
||||||
|
<p class="mt-0.5 text-xs text-muted-foreground">{q.context.why}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if q.context.entities?.length}
|
||||||
|
<div class="ml-6 flex flex-wrap gap-1">
|
||||||
|
{#each q.context.entities as slug}
|
||||||
|
<span class="rounded bg-muted px-1.5 py-0.5 font-mono text-[10px]">{slug}</span>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if q.context.options?.length}
|
||||||
|
<div class="ml-6 flex flex-wrap gap-1.5">
|
||||||
|
{#each q.context.options as opt}
|
||||||
|
<Button size="sm" variant="outline" class="h-7 px-2.5 text-xs" disabled={submitting} onclick={() => submit(opt)}>
|
||||||
|
{opt}
|
||||||
|
</Button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<div class="ml-6 flex items-end gap-1.5">
|
||||||
|
<Textarea
|
||||||
|
bind:value={freeText}
|
||||||
|
placeholder="Or type an answer…"
|
||||||
|
rows={1}
|
||||||
|
class="max-h-24 min-h-0 resize-none text-xs"
|
||||||
|
disabled={submitting}
|
||||||
|
onkeydown={(e) => {
|
||||||
|
if (e.key === 'Enter' && !e.shiftKey) {
|
||||||
|
e.preventDefault()
|
||||||
|
submit(freeText)
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<Button size="sm" class="h-7 px-2.5 text-xs" disabled={!freeText.trim() || submitting} onclick={() => submit(freeText)}>
|
||||||
|
Send
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
77
web/src/lib/components/PlanProgress.svelte
Normal file
77
web/src/lib/components/PlanProgress.svelte
Normal file
@@ -0,0 +1,77 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { planSteps } from '$lib/stores/workspace'
|
||||||
|
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
||||||
|
import CircleIcon from '@lucide/svelte/icons/circle'
|
||||||
|
import LoaderCircleIcon from '@lucide/svelte/icons/loader-circle'
|
||||||
|
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
||||||
|
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
||||||
|
import CircleSlashIcon from '@lucide/svelte/icons/circle-slash'
|
||||||
|
import CirclePauseIcon from '@lucide/svelte/icons/circle-pause'
|
||||||
|
|
||||||
|
const done = $derived($planSteps.filter((s) => s.status === 'done').length)
|
||||||
|
const total = $derived($planSteps.length)
|
||||||
|
const pct = $derived(total > 0 ? Math.round((done / total) * 100) : 0)
|
||||||
|
|
||||||
|
let sheetSlug = $state<string | null>(null)
|
||||||
|
let sheetOpen = $state(false)
|
||||||
|
|
||||||
|
// Tool calls don't carry a step id, so a step can't be linked to its exact
|
||||||
|
// transcript entry — but its target entity IS known, and EntitySheet already
|
||||||
|
// gives a real, working detail view for any slug. Clicking a step with a
|
||||||
|
// target opens that, rather than a fake "scroll to it" that would silently
|
||||||
|
// no-op for a collapsed tool-call group.
|
||||||
|
function openStep(targetSlug: string | undefined) {
|
||||||
|
if (!targetSlug) return
|
||||||
|
sheetSlug = targetSlug
|
||||||
|
sheetOpen = true
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if total > 0}
|
||||||
|
<div class="flex shrink-0 flex-col gap-2 border-b px-3 py-2.5">
|
||||||
|
<div class="flex items-center justify-between text-[11px] text-muted-foreground">
|
||||||
|
<span class="font-semibold uppercase tracking-wider">Plan</span>
|
||||||
|
<span>{done}/{total}</span>
|
||||||
|
</div>
|
||||||
|
<div class="h-1 w-full overflow-hidden rounded-full bg-muted">
|
||||||
|
<div class="h-full rounded-full bg-primary transition-all duration-500" style="width: {pct}%"></div>
|
||||||
|
</div>
|
||||||
|
<ol class="flex flex-col gap-1">
|
||||||
|
{#each $planSteps as step (step.id)}
|
||||||
|
<li>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="flex w-full items-start gap-2 rounded px-1 py-1 text-left text-xs {step.target_slug ? 'hover:bg-muted/50' : 'cursor-default'}"
|
||||||
|
onclick={() => openStep(step.target_slug)}
|
||||||
|
>
|
||||||
|
<span class="mt-0.5 shrink-0">
|
||||||
|
{#if step.status === 'done'}
|
||||||
|
<CircleCheckIcon class="size-3.5 text-success" />
|
||||||
|
{:else if step.status === 'failed'}
|
||||||
|
<CircleXIcon class="size-3.5 text-destructive" />
|
||||||
|
{:else if step.status === 'running'}
|
||||||
|
<LoaderCircleIcon class="size-3.5 animate-spin text-primary" />
|
||||||
|
{:else if step.status === 'skipped'}
|
||||||
|
<CircleSlashIcon class="size-3.5 text-muted-foreground" />
|
||||||
|
{:else if step.status === 'blocked'}
|
||||||
|
<CirclePauseIcon class="size-3.5 text-warning" />
|
||||||
|
{:else}
|
||||||
|
<CircleIcon class="size-3.5 text-muted-foreground" />
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
<span class="min-w-0 flex-1">
|
||||||
|
<span class="block leading-snug {step.status === 'done' ? 'text-muted-foreground line-through decoration-muted-foreground/40' : ''}">
|
||||||
|
{step.title}
|
||||||
|
</span>
|
||||||
|
{#if step.target_slug}
|
||||||
|
<span class="font-mono text-[10px] text-muted-foreground">{step.target_slug}</span>
|
||||||
|
{/if}
|
||||||
|
</span>
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ol>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<EntitySheet slug={sheetSlug} bind:open={sheetOpen} />
|
||||||
@@ -1,24 +1,32 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { fetchSessionDigest, type SessionDigest } from '$lib/api'
|
import { fetchSessionDigest, type SessionDigest } from '$lib/api'
|
||||||
import { currentSession, streaming } from '$lib/stores/chat'
|
import { currentSession, streaming } from '$lib/stores/chat'
|
||||||
|
import { currentTask } from '$lib/stores/workspace'
|
||||||
import { Badge } from '$lib/components/ui/badge'
|
import { Badge } from '$lib/components/ui/badge'
|
||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
||||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
||||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||||
|
import CircleCheckIcon from '@lucide/svelte/icons/circle-check'
|
||||||
|
import CircleXIcon from '@lucide/svelte/icons/circle-x'
|
||||||
|
|
||||||
let digest = $state<SessionDigest | null>(null)
|
let digest = $state<SessionDigest | null>(null)
|
||||||
let open = $state(false)
|
let open = $state(false)
|
||||||
let loadedFor = $state<string | null>(null)
|
// Keyed on session id AND status: a task that completes mid-view (via
|
||||||
|
// resumeSession running server-side, with $streaming never true here) must
|
||||||
|
// still refetch once outcome/summary land, not just on session switch.
|
||||||
|
let loadedKey = $state<string | null>(null)
|
||||||
|
|
||||||
// Reload the digest whenever the session changes or a stream finishes —
|
// Reload the digest whenever the session changes, the task's status changes
|
||||||
// "what did this session actually do" is only meaningful once executions
|
// (e.g. it just completed), or a stream finishes — "what did this session
|
||||||
// have had a chance to land.
|
// actually do" is only meaningful once executions have had a chance to land.
|
||||||
$effect(() => {
|
$effect(() => {
|
||||||
const sid = $currentSession
|
const sid = $currentSession
|
||||||
const busy = $streaming
|
const busy = $streaming
|
||||||
|
const status = $currentTask?.status ?? ''
|
||||||
if (!sid || busy) return
|
if (!sid || busy) return
|
||||||
if (loadedFor === sid) return
|
const key = `${sid}:${status}`
|
||||||
loadedFor = sid
|
if (loadedKey === key) return
|
||||||
|
loadedKey = key
|
||||||
fetchSessionDigest(sid).then((d) => (digest = d))
|
fetchSessionDigest(sid).then((d) => (digest = d))
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -30,6 +38,23 @@
|
|||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
{#if $currentTask?.outcome}
|
||||||
|
<div
|
||||||
|
class="flex items-start gap-2 border-b px-3 py-2 text-xs {$currentTask.outcome === 'failure'
|
||||||
|
? 'bg-destructive/5 text-destructive'
|
||||||
|
: $currentTask.outcome === 'partial'
|
||||||
|
? 'bg-warning/5 text-warning'
|
||||||
|
: 'bg-success/5 text-success'}"
|
||||||
|
>
|
||||||
|
{#if $currentTask.outcome === 'failure'}
|
||||||
|
<CircleXIcon class="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
{:else}
|
||||||
|
<CircleCheckIcon class="mt-0.5 size-3.5 shrink-0" />
|
||||||
|
{/if}
|
||||||
|
<span class="leading-snug">{$currentTask.summary || `Task ${$currentTask.outcome}.`}</span>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
{#if digest && digest.total_executions > 0}
|
{#if digest && digest.total_executions > 0}
|
||||||
<div class="border-b">
|
<div class="border-b">
|
||||||
<button
|
<button
|
||||||
|
|||||||
@@ -12,6 +12,7 @@
|
|||||||
} from 'd3-force'
|
} from 'd3-force'
|
||||||
import { fetchGraph, type Entity } from '$lib/api'
|
import { fetchGraph, type Entity } from '$lib/api'
|
||||||
import { messages } from '$lib/stores/chat'
|
import { messages } from '$lib/stores/chat'
|
||||||
|
import { touched, healthDiffs } from '$lib/stores/workspace'
|
||||||
import { relativeTime } from '$lib/utils'
|
import { relativeTime } from '$lib/utils'
|
||||||
import { Badge } from '$lib/components/ui/badge'
|
import { Badge } from '$lib/components/ui/badge'
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
@@ -226,6 +227,21 @@
|
|||||||
return slug.split(':').pop() ?? slug
|
return slug.split(':').pop() ?? slug
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Live touch/health-diff lookups, keyed by slug for O(1) per-node checks
|
||||||
|
// during render. Kept as plain objects (not Maps) since Svelte 5 runes track
|
||||||
|
// object identity fine and this is small (≤12 touched, ≤8 diffs).
|
||||||
|
const touchedBySlug = $derived.by(() => {
|
||||||
|
const m: Record<string, true> = {}
|
||||||
|
for (const t of $touched) m[t.slug] = true
|
||||||
|
return m
|
||||||
|
})
|
||||||
|
const diffBySlug = $derived.by(() => {
|
||||||
|
const m: Record<string, { from: string; to: string }> = {}
|
||||||
|
for (const d of $healthDiffs) if (!(d.slug in m)) m[d.slug] = d
|
||||||
|
return m
|
||||||
|
})
|
||||||
|
const nowTouching = $derived($touched[0] ?? null)
|
||||||
|
|
||||||
function endpoint(end: string | Node): Node | undefined {
|
function endpoint(end: string | Node): Node | undefined {
|
||||||
return typeof end === 'object' ? end : nodes.find((n) => n.slug === end)
|
return typeof end === 'object' ? end : nodes.find((n) => n.slug === end)
|
||||||
}
|
}
|
||||||
@@ -290,6 +306,12 @@
|
|||||||
<span class="text-[11px] text-muted-foreground">{nodes.length} {nodes.length === 1 ? 'entity' : 'entities'}</span>
|
<span class="text-[11px] text-muted-foreground">{nodes.length} {nodes.length === 1 ? 'entity' : 'entities'}</span>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
{#if nowTouching}
|
||||||
|
<div class="flex shrink-0 items-center gap-1.5 border-b bg-primary/5 px-3 py-1.5 text-[11px] text-primary">
|
||||||
|
<span class="size-1.5 animate-pulse rounded-full bg-primary"></span>
|
||||||
|
Now touching <code class="font-mono">{nowTouching.slug}</code>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
<div bind:this={container} class="relative min-h-0 flex-1 overflow-hidden">
|
<div bind:this={container} class="relative min-h-0 flex-1 overflow-hidden">
|
||||||
{#if nodes.length === 0}
|
{#if nodes.length === 0}
|
||||||
@@ -353,6 +375,8 @@
|
|||||||
{@const r = nodeRadius(node)}
|
{@const r = nodeRadius(node)}
|
||||||
{@const isSel = selected?.slug === node.slug}
|
{@const isSel = selected?.slug === node.slug}
|
||||||
{@const dim = selected && !isSel && !selectedRelations.some((rel) => rel.other === node.slug)}
|
{@const dim = selected && !isSel && !selectedRelations.some((rel) => rel.other === node.slug)}
|
||||||
|
{@const isTouched = node.slug in touchedBySlug}
|
||||||
|
{@const diff = diffBySlug[node.slug]}
|
||||||
<g
|
<g
|
||||||
transform="translate({node.x},{node.y})"
|
transform="translate({node.x},{node.y})"
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
@@ -365,6 +389,12 @@
|
|||||||
{#if isSel}
|
{#if isSel}
|
||||||
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
|
<circle r={r + 5} fill={nodeColor(node)} opacity="0.25" />
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if isTouched}
|
||||||
|
<circle r={r + 4} fill="none" stroke="var(--primary)" stroke-width="1.5" opacity="0.8">
|
||||||
|
<animate attributeName="r" values="{r + 3};{r + 8};{r + 3}" dur="1.6s" repeatCount="indefinite" />
|
||||||
|
<animate attributeName="opacity" values="0.8;0.1;0.8" dur="1.6s" repeatCount="indefinite" />
|
||||||
|
</circle>
|
||||||
|
{/if}
|
||||||
<circle r={r} fill={nodeColor(node)} stroke={isSel ? 'var(--foreground)' : 'var(--background)'} stroke-width={isSel ? 2 : 1.5} />
|
<circle r={r} fill={nodeColor(node)} stroke={isSel ? 'var(--foreground)' : 'var(--background)'} stroke-width={isSel ? 2 : 1.5} />
|
||||||
<text
|
<text
|
||||||
y={r + 10}
|
y={r + 10}
|
||||||
@@ -378,6 +408,20 @@
|
|||||||
>
|
>
|
||||||
{shortName(node.slug)}
|
{shortName(node.slug)}
|
||||||
</text>
|
</text>
|
||||||
|
{#if diff}
|
||||||
|
<text
|
||||||
|
y={-r - 6}
|
||||||
|
text-anchor="middle"
|
||||||
|
font-size="8"
|
||||||
|
fill="var(--warning)"
|
||||||
|
paint-order="stroke"
|
||||||
|
stroke="var(--background)"
|
||||||
|
stroke-width="2.5"
|
||||||
|
class="pointer-events-none"
|
||||||
|
>
|
||||||
|
{diff.from} → {diff.to}
|
||||||
|
</text>
|
||||||
|
{/if}
|
||||||
</g>
|
</g>
|
||||||
{/if}
|
{/if}
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
@@ -44,27 +44,29 @@
|
|||||||
<ScrollArea class="min-h-0 flex-1">
|
<ScrollArea class="min-h-0 flex-1">
|
||||||
<div class="flex flex-col gap-1 pr-2">
|
<div class="flex flex-col gap-1 pr-2">
|
||||||
{#each $sessions as session (session.id)}
|
{#each $sessions as session (session.id)}
|
||||||
<button
|
<div class="group relative">
|
||||||
type="button"
|
<button
|
||||||
class="group flex flex-col items-start gap-0.5 rounded-md border px-2 py-1.5 text-left text-xs transition-colors hover:bg-muted/60 {$currentSession === session.id ? 'border-primary bg-muted/50' : 'border-transparent'}"
|
type="button"
|
||||||
onclick={() => handleClick(session.id)}
|
class="flex w-full flex-col items-start gap-0.5 rounded-md border py-1.5 pl-2 pr-7 text-left text-xs transition-colors hover:bg-muted/60 {$currentSession === session.id ? 'border-primary bg-muted/50' : 'border-transparent'}"
|
||||||
>
|
onclick={() => handleClick(session.id)}
|
||||||
<span class="flex w-full items-center justify-between gap-1">
|
>
|
||||||
<span class="min-w-0 truncate font-medium">{session.title || 'Untitled'}</span>
|
<span class="min-w-0 max-w-full truncate font-medium">{session.title || 'Untitled'}</span>
|
||||||
<span
|
<span class="text-[11px] text-muted-foreground">{relativeTime(session.last_active_at)}</span>
|
||||||
class="shrink-0 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 hover:bg-destructive/20 hover:text-destructive"
|
</button>
|
||||||
onclick={(e) => handleDelete(e, session.id)}
|
<button
|
||||||
title={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
|
type="button"
|
||||||
>
|
class="absolute right-1 top-1.5 shrink-0 rounded p-0.5 opacity-0 transition-opacity group-hover:opacity-100 focus-visible:opacity-100 hover:bg-destructive/20 hover:text-destructive"
|
||||||
{#if confirmDelete === session.id}
|
onclick={(e) => handleDelete(e, session.id)}
|
||||||
<span class="text-[10px] font-semibold text-destructive">Sure?</span>
|
aria-label={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
|
||||||
{:else}
|
title={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
|
||||||
<Trash2Icon class="size-3" />
|
>
|
||||||
{/if}
|
{#if confirmDelete === session.id}
|
||||||
</span>
|
<span class="text-[10px] font-semibold text-destructive">Sure?</span>
|
||||||
</span>
|
{:else}
|
||||||
<span class="text-[11px] text-muted-foreground">{relativeTime(session.last_active_at)}</span>
|
<Trash2Icon class="size-3" />
|
||||||
</button>
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
<p class="px-2 py-4 text-center text-xs text-muted-foreground">No sessions yet.</p>
|
<p class="px-2 py-4 text-center text-xs text-muted-foreground">No sessions yet.</p>
|
||||||
{/each}
|
{/each}
|
||||||
|
|||||||
29
web/src/lib/components/TaskContextPanel.svelte
Normal file
29
web/src/lib/components/TaskContextPanel.svelte
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
import { startWorkspace } from '$lib/stores/workspace'
|
||||||
|
import GoalHeader from './GoalHeader.svelte'
|
||||||
|
import PlanProgress from './PlanProgress.svelte'
|
||||||
|
import OperatorQuestion from './OperatorQuestion.svelte'
|
||||||
|
import SessionGraph from './SessionGraph.svelte'
|
||||||
|
import SessionDigest from './SessionDigest.svelte'
|
||||||
|
|
||||||
|
onMount(() => startWorkspace())
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!--
|
||||||
|
The task's live control panel: goal + status, plan progress, a pinned
|
||||||
|
question when the agent needs a decision, the live entity graph (pulses what
|
||||||
|
the agent is touching, flags health changes), and the outcome/knowledge
|
||||||
|
record once the task completes. Driven by the always-on events stream
|
||||||
|
(see workspace.ts) so it keeps updating during server-side auto-continuation,
|
||||||
|
not just while a chat turn is streaming.
|
||||||
|
-->
|
||||||
|
<div class="flex h-full min-h-0 flex-col">
|
||||||
|
<GoalHeader />
|
||||||
|
<PlanProgress />
|
||||||
|
<OperatorQuestion />
|
||||||
|
<div class="min-h-0 flex-1">
|
||||||
|
<SessionGraph />
|
||||||
|
</div>
|
||||||
|
<SessionDigest />
|
||||||
|
</div>
|
||||||
@@ -71,7 +71,18 @@ export const sessions = writable<Session[]>([])
|
|||||||
export const sessionMessages = writable<Message[]>([])
|
export const sessionMessages = writable<Message[]>([])
|
||||||
export const error = writable<string | null>(null)
|
export const error = writable<string | null>(null)
|
||||||
|
|
||||||
let activeController: AbortController | null = null
|
// Per-session controller tracking. Multiple tasks can stream concurrently
|
||||||
|
// (see sendMessage's session guard above this used to be a single global
|
||||||
|
// `activeController`, which meant cancelStream()/newChat() always aborted
|
||||||
|
// whichever stream happened to be the MOST RECENTLY started one, regardless
|
||||||
|
// of what the operator was currently viewing — starting Task A, switching to
|
||||||
|
// (already-loaded) Task B, then clicking "New task" would silently abort
|
||||||
|
// Task A's still-running turn even though the operator was never looking at
|
||||||
|
// it and never asked to cancel it. Keyed by session id once known;
|
||||||
|
// pendingController covers the brief window for a brand-new task between
|
||||||
|
// streamChat() starting and its 'session' event assigning a real id.
|
||||||
|
const activeControllers = new Map<string, AbortController>()
|
||||||
|
let pendingController: AbortController | null = null
|
||||||
|
|
||||||
export async function loadSessions() {
|
export async function loadSessions() {
|
||||||
const list = await fetchSessions()
|
const list = await fetchSessions()
|
||||||
@@ -110,6 +121,15 @@ function toChatMessages(msgs: Message[]): ChatMessage[] {
|
|||||||
|
|
||||||
export async function loadSessionMessages(sessionId: string) {
|
export async function loadSessionMessages(sessionId: string) {
|
||||||
currentSession.set(sessionId)
|
currentSession.set(sessionId)
|
||||||
|
// This is a fresh view of sessionId's current (REST-loaded) state — reset
|
||||||
|
// streaming regardless of whether some OTHER task's stream happens to still
|
||||||
|
// be in flight in the background. Without this, switching to a task while
|
||||||
|
// a different one is mid-turn could leave `streaming` stuck true here (that
|
||||||
|
// other stream's completion callback now correctly skips touching it, per
|
||||||
|
// sendMessage's session guard) — which would disable the input AND silently
|
||||||
|
// stop startPolling's loop from ever applying updates (it bails while
|
||||||
|
// $streaming is true), making the newly-opened task look frozen.
|
||||||
|
streaming.set(false)
|
||||||
const msgs = await fetchMessages(sessionId)
|
const msgs = await fetchMessages(sessionId)
|
||||||
sessionMessages.set(msgs)
|
sessionMessages.set(msgs)
|
||||||
messages.set(toChatMessages(msgs))
|
messages.set(toChatMessages(msgs))
|
||||||
@@ -181,13 +201,46 @@ export function sendMessage(text: string) {
|
|||||||
|
|
||||||
let activeTools: Map<string, ToolCallResult> = new Map()
|
let activeTools: Map<string, ToolCallResult> = new Map()
|
||||||
|
|
||||||
activeController = streamChat(
|
// Multiple tasks can stream concurrently (the backend runs each turn as its
|
||||||
|
// own goroutine — nothing serializes them), but `messages`/`currentSession`
|
||||||
|
// are a single global view. Without this guard, switching to a different
|
||||||
|
// task while this stream is still open lets its later events (tool_use,
|
||||||
|
// text_delta, ..., and worst of all the 'done' handler's
|
||||||
|
// currentSession.set) get applied to whatever the operator is NOW looking
|
||||||
|
// at — silently corrupting another task's transcript, or yanking the view
|
||||||
|
// back to this one. openedFor is the session this call started for (null
|
||||||
|
// for a brand-new task, until the 'session' event assigns the real id);
|
||||||
|
// every branch below checks the CURRENT $currentSession still matches
|
||||||
|
// before touching `messages`. The task itself keeps running server-side
|
||||||
|
// regardless — dropped events just mean the live view isn't watching it;
|
||||||
|
// navigating back re-hydrates via REST/poll same as it already does for
|
||||||
|
// auto-continuation.
|
||||||
|
const openedFor = get(currentSession)
|
||||||
|
let streamSessionID = openedFor
|
||||||
|
|
||||||
|
const controller = streamChat(
|
||||||
text,
|
text,
|
||||||
get(currentSession), // continue the active session so the agent keeps context
|
get(currentSession), // continue the active session so the agent keeps context
|
||||||
(ev: ChatEvent) => {
|
(ev: ChatEvent) => {
|
||||||
if (ev.type === 'session') {
|
if (ev.type === 'session') {
|
||||||
currentSession.set(ev.data)
|
streamSessionID = ev.data
|
||||||
} else if (ev.type === 'tool_use') {
|
// Move this stream's controller into the per-session map now that its
|
||||||
|
// real id is known, so a later cancelStream()/newChat() from THIS
|
||||||
|
// session's view can find and abort it — and, just as importantly,
|
||||||
|
// so cancelling/leaving a DIFFERENT session never reaches this one.
|
||||||
|
// For a continued (non-new) session, openedFor already equals ev.data
|
||||||
|
// and the controller was stored under that key at creation below;
|
||||||
|
// this only does real work for a brand-new task's first assignment.
|
||||||
|
if (pendingController === controller) pendingController = null
|
||||||
|
activeControllers.set(ev.data, controller)
|
||||||
|
// Only claim currentSession if the operator hasn't already navigated
|
||||||
|
// to something else since this call started (openedFor covers both
|
||||||
|
// "still on the task I was on" and "still hadn't opened one yet").
|
||||||
|
if (get(currentSession) === openedFor) currentSession.set(ev.data)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (get(currentSession) !== streamSessionID) return // stream's task isn't the one on screen — drop
|
||||||
|
if (ev.type === 'tool_use') {
|
||||||
const tr: ToolCallResult = {
|
const tr: ToolCallResult = {
|
||||||
type: 'tool_use',
|
type: 'tool_use',
|
||||||
name: ev.data.name,
|
name: ev.data.name,
|
||||||
@@ -248,24 +301,43 @@ export function sendMessage(text: string) {
|
|||||||
return [...ms]
|
return [...ms]
|
||||||
})
|
})
|
||||||
const sid = ev.data?.session_id ?? ev.session_id
|
const sid = ev.data?.session_id ?? ev.session_id
|
||||||
currentSession.set(sid)
|
|
||||||
// Start polling for auto-continuation results now that the live turn
|
// Start polling for auto-continuation results now that the live turn
|
||||||
// is over — this is what makes an approved plan's later steps show up
|
// is over — this is what makes an approved plan's later steps show up
|
||||||
// on their own instead of requiring a manual reload.
|
// on their own instead of requiring a manual reload. (startPolling's
|
||||||
|
// own loop already re-checks $currentSession before applying results,
|
||||||
|
// so this is safe to call even if the operator has since navigated
|
||||||
|
// elsewhere — it just won't visibly do anything until/unless they
|
||||||
|
// come back.)
|
||||||
if (sid) startPolling(sid)
|
if (sid) startPolling(sid)
|
||||||
} else if (ev.type === 'error') {
|
} else if (ev.type === 'error') {
|
||||||
error.set(ev.data)
|
error.set(ev.data)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
(err: string) => {
|
(err: string) => {
|
||||||
error.set(err)
|
if (get(currentSession) === streamSessionID) error.set(err)
|
||||||
},
|
},
|
||||||
() => {
|
() => {
|
||||||
streaming.set(false)
|
if (get(currentSession) === streamSessionID) streaming.set(false)
|
||||||
activeController = null
|
// Clean up whichever slot this controller ended up in — normally
|
||||||
|
// activeControllers[streamSessionID] once the 'session' event has
|
||||||
|
// fired, but fall back to pendingController for the (rare) case where
|
||||||
|
// the stream errored/completed before ever getting one.
|
||||||
|
if (streamSessionID && activeControllers.get(streamSessionID) === controller) {
|
||||||
|
activeControllers.delete(streamSessionID)
|
||||||
|
}
|
||||||
|
if (pendingController === controller) pendingController = null
|
||||||
loadSessions()
|
loadSessions()
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// Register immediately (not just inside the 'session' handler above) so a
|
||||||
|
// cancelStream() during the brief pre-'session' window for a CONTINUED
|
||||||
|
// session (openedFor already known) can find it right away.
|
||||||
|
if (openedFor) {
|
||||||
|
activeControllers.set(openedFor, controller)
|
||||||
|
} else {
|
||||||
|
pendingController = controller
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
export function newChat() {
|
export function newChat() {
|
||||||
@@ -274,14 +346,27 @@ export function newChat() {
|
|||||||
currentSession.set(null)
|
currentSession.set(null)
|
||||||
messages.set([])
|
messages.set([])
|
||||||
error.set(null)
|
error.set(null)
|
||||||
|
streaming.set(false) // fresh view — see loadSessionMessages for why this must not depend on cancelStream's own reset
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Cancels the stream for whatever the operator is CURRENTLY VIEWING — never
|
||||||
|
// some other, unrelated task's background stream. Before per-session
|
||||||
|
// tracking, this aborted a single global `activeController`, which meant it
|
||||||
|
// always targeted the MOST RECENTLY STARTED stream regardless of what was on
|
||||||
|
// screen: start Task A, switch to already-loaded Task B, click "New task" —
|
||||||
|
// newChat()'s cancelStream() would silently abort Task A's still-running
|
||||||
|
// turn, even though the operator was never looking at it and never asked to
|
||||||
|
// cancel it. Now it looks up by $currentSession (or pendingController for
|
||||||
|
// the brief pre-'session'-event window of a just-started new task) so it can
|
||||||
|
// only ever touch the stream that belongs to the view being left.
|
||||||
export function cancelStream() {
|
export function cancelStream() {
|
||||||
if (activeController) {
|
const sid = get(currentSession)
|
||||||
activeController.abort()
|
const controller = sid ? activeControllers.get(sid) : pendingController
|
||||||
activeController = null
|
if (!controller) return
|
||||||
streaming.set(false)
|
controller.abort()
|
||||||
}
|
if (sid) activeControllers.delete(sid)
|
||||||
|
if (pendingController === controller) pendingController = null
|
||||||
|
streaming.set(false)
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function deleteSession(sessionId: string) {
|
export async function deleteSession(sessionId: string) {
|
||||||
|
|||||||
204
web/src/lib/stores/workspace.ts
Normal file
204
web/src/lib/stores/workspace.ts
Normal file
@@ -0,0 +1,204 @@
|
|||||||
|
import { writable, derived, get } from 'svelte/store'
|
||||||
|
import { liveEvents, subscribeEvents } from './events'
|
||||||
|
import { currentSession, sessions, loadSessions } from './chat'
|
||||||
|
import { fetchPlan, fetchQuestions, type PlanStep, type SessionQuestion } from '$lib/api'
|
||||||
|
|
||||||
|
// workspace.ts is the live "what is this task doing right now" surface for the
|
||||||
|
// TaskContextPanel: plan progress, the pinned operator question, and entities
|
||||||
|
// the agent is touching or whose health just changed. It is deliberately driven
|
||||||
|
// by the ALWAYS-ON global events stream (subscribeEvents), not the per-turn
|
||||||
|
// chat SSE — the auto-continuation worker and resumeSession run entirely
|
||||||
|
// server-side with no chat turn open, so a chat-bound panel would go stale
|
||||||
|
// exactly when the agent is working autonomously. This also means the panel
|
||||||
|
// keeps updating across a tab reload: hydrate() re-fetches REST state, then
|
||||||
|
// live events carry deltas from there.
|
||||||
|
|
||||||
|
export const planSteps = writable<PlanStep[]>([])
|
||||||
|
export const questions = writable<SessionQuestion[]>([])
|
||||||
|
export const openQuestion = derived(questions, (qs) => qs.find((q) => q.status === 'open') ?? null)
|
||||||
|
|
||||||
|
export interface TouchedEntity {
|
||||||
|
slug: string
|
||||||
|
tool: string
|
||||||
|
ts: number
|
||||||
|
}
|
||||||
|
export const touched = writable<TouchedEntity[]>([])
|
||||||
|
const TOUCHED_MAX = 12
|
||||||
|
const TOUCHED_PULSE_MS = 6000
|
||||||
|
|
||||||
|
export interface HealthDiff {
|
||||||
|
slug: string
|
||||||
|
from: string
|
||||||
|
to: string
|
||||||
|
ts: number
|
||||||
|
}
|
||||||
|
export const healthDiffs = writable<HealthDiff[]>([])
|
||||||
|
const HEALTH_DIFF_MS = 8000
|
||||||
|
|
||||||
|
// The task's own fields (goal/status/outcome/summary) live on the session row.
|
||||||
|
// Rather than a dedicated endpoint, derive from the sessions list (already
|
||||||
|
// fetched for the task board) and keep it fresh here on task-lifecycle events.
|
||||||
|
export const currentTask = derived([sessions, currentSession], ([$sessions, $id]) =>
|
||||||
|
$sessions.find((s) => s.id === $id) ?? null
|
||||||
|
)
|
||||||
|
|
||||||
|
// Events that can change agent_sessions.status/goal/outcome — see applyEvent.
|
||||||
|
const STATUS_AFFECTING = new Set([
|
||||||
|
'goal.set', 'task.status', 'plan.proposed', 'question.raised', 'question.answered'
|
||||||
|
])
|
||||||
|
|
||||||
|
let hydratedFor: string | null = null
|
||||||
|
let unsubStream: (() => void) | null = null
|
||||||
|
let unsubLive: (() => void) | null = null
|
||||||
|
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
let lastSeenId = 0
|
||||||
|
|
||||||
|
async function hydrate(sessionId: string) {
|
||||||
|
hydratedFor = sessionId
|
||||||
|
planSteps.set([])
|
||||||
|
questions.set([])
|
||||||
|
touched.set([])
|
||||||
|
healthDiffs.set([])
|
||||||
|
const [steps, qs] = await Promise.all([fetchPlan(sessionId), fetchQuestions(sessionId)])
|
||||||
|
if (get(currentSession) !== sessionId) return // switched away while loading
|
||||||
|
planSteps.set(steps)
|
||||||
|
questions.set(qs)
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyPlanStepEvent(sessionId: string, type: string, data: any) {
|
||||||
|
const stepID = data?.step_id as string | undefined
|
||||||
|
const seq = data?.seq as number | undefined
|
||||||
|
planSteps.update((steps) => {
|
||||||
|
const i = steps.findIndex((s) => (stepID && s.id === stepID) || (seq != null && s.seq === seq))
|
||||||
|
if (i === -1) return steps
|
||||||
|
const next = [...steps]
|
||||||
|
next[i] = { ...next[i], status: data.status ?? next[i].status, execution_id: data.execution_id ?? next[i].execution_id }
|
||||||
|
return next
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function applyEvent(ev: { type: string; correlation_id?: string | null; data?: unknown }) {
|
||||||
|
const sid = get(currentSession)
|
||||||
|
if (!sid || ev.correlation_id !== sid) return
|
||||||
|
const data = (ev.data ?? {}) as any
|
||||||
|
|
||||||
|
// Task fields (status/goal/outcome) live on the session row — refetch the
|
||||||
|
// (cheap) session list so GoalHeader picks up the change without a
|
||||||
|
// dedicated endpoint. Every event that can change agent_sessions.status
|
||||||
|
// (goal.set → planning, propose_plan → executing, ask_operator →
|
||||||
|
// awaiting_input, answerQuestion → executing, complete_task → done/failed)
|
||||||
|
// must trigger this, not just goal.set/task.status — otherwise the status
|
||||||
|
// pill goes stale exactly when resumeSession runs the next turn entirely
|
||||||
|
// server-side, with no client-streaming 'done' event to piggyback a refresh
|
||||||
|
// on (found live: answering a question via the panel left the header stuck
|
||||||
|
// on "Needs your input" after the agent had already resumed). Debounced
|
||||||
|
// since several of these can land in one burst.
|
||||||
|
if (STATUS_AFFECTING.has(ev.type)) {
|
||||||
|
if (refreshTimer) clearTimeout(refreshTimer)
|
||||||
|
refreshTimer = setTimeout(() => loadSessions(), 300)
|
||||||
|
}
|
||||||
|
|
||||||
|
switch (ev.type) {
|
||||||
|
case 'plan.proposed':
|
||||||
|
if (Array.isArray(data.steps)) {
|
||||||
|
const incoming = data.steps.map((s: any) => ({
|
||||||
|
id: s.id, seq: s.seq, title: s.title, detail: s.detail ?? '',
|
||||||
|
status: 'pending' as const, target_slug: s.target_slug || undefined
|
||||||
|
}))
|
||||||
|
// The server appends rather than replaces once any step has started
|
||||||
|
// (see store.go proposePlan) — mirror that here so a model that calls
|
||||||
|
// propose_plan once per step still shows the FULL running history in
|
||||||
|
// the panel, not just its latest call's single step.
|
||||||
|
planSteps.update((existing) => (data.appended ? [...existing, ...incoming] : incoming))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'plan.step.started':
|
||||||
|
case 'plan.step.finished':
|
||||||
|
applyPlanStepEvent(sid, ev.type, data)
|
||||||
|
break
|
||||||
|
case 'question.raised':
|
||||||
|
questions.update((qs) => [
|
||||||
|
{
|
||||||
|
id: data.question_id, prompt: data.prompt ?? '',
|
||||||
|
context: { why: data.why, options: data.options, entities: data.entities },
|
||||||
|
status: 'open', created_at: new Date().toISOString()
|
||||||
|
},
|
||||||
|
...qs.filter((q) => q.id !== data.question_id)
|
||||||
|
])
|
||||||
|
break
|
||||||
|
case 'question.answered':
|
||||||
|
questions.update((qs) =>
|
||||||
|
qs.map((q) => (q.id === data.question_id ? { ...q, status: 'answered', answer: data.answer } : q))
|
||||||
|
)
|
||||||
|
break
|
||||||
|
case 'entity.touched':
|
||||||
|
if (data.slug) {
|
||||||
|
const now = Date.now()
|
||||||
|
touched.update((t) => [{ slug: data.slug, tool: data.tool ?? '', ts: now }, ...t].slice(0, TOUCHED_MAX))
|
||||||
|
}
|
||||||
|
break
|
||||||
|
case 'knowledge.recorded':
|
||||||
|
// No dedicated store yet — the outcome/knowledge card reads this task's
|
||||||
|
// digest (fetchSessionDigest) on completion, which already lists it.
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// health.changed is task-agnostic (fleet-wide), so it's matched separately:
|
||||||
|
// show the diff whenever the changed entity is one this task has touched, not
|
||||||
|
// by correlation_id (health events don't carry one).
|
||||||
|
function applyHealthChanged(ev: { type: string; data?: unknown }) {
|
||||||
|
if (ev.type !== 'health.changed') return
|
||||||
|
const data = (ev.data ?? {}) as any
|
||||||
|
if (!data.slug) return
|
||||||
|
const isRelevant = get(touched).some((t) => t.slug === data.slug)
|
||||||
|
if (!isRelevant) return
|
||||||
|
healthDiffs.update((d) => [{ slug: data.slug, from: data.from, to: data.to, ts: Date.now() }, ...d].slice(0, 8))
|
||||||
|
}
|
||||||
|
|
||||||
|
// startWorkspace opens the global event subscription and begins tracking the
|
||||||
|
// active session. Call once from the panel's onMount; call the returned
|
||||||
|
// cleanup on unmount. Safe to call multiple times (ref-counted underneath).
|
||||||
|
export function startWorkspace(): () => void {
|
||||||
|
unsubStream = subscribeEvents()
|
||||||
|
|
||||||
|
const unsubSession = currentSession.subscribe((sid) => {
|
||||||
|
if (sid && sid !== hydratedFor) hydrate(sid)
|
||||||
|
if (!sid) {
|
||||||
|
hydratedFor = null
|
||||||
|
planSteps.set([])
|
||||||
|
questions.set([])
|
||||||
|
touched.set([])
|
||||||
|
healthDiffs.set([])
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
unsubLive = liveEvents.subscribe((evs) => {
|
||||||
|
if (evs.length === 0) return
|
||||||
|
const maxId = evs[0].id
|
||||||
|
if (maxId <= lastSeenId) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const fresh = evs.filter((e) => e.id > lastSeenId)
|
||||||
|
lastSeenId = maxId
|
||||||
|
// Oldest-first application so ordering (e.g. plan.step.started before
|
||||||
|
// .finished) is preserved.
|
||||||
|
for (const e of fresh.slice().reverse()) {
|
||||||
|
applyEvent(e)
|
||||||
|
applyHealthChanged(e)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
unsubSession()
|
||||||
|
unsubLive?.()
|
||||||
|
unsubStream?.()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sweep expired pulses/diffs on an interval so old touches stop glowing.
|
||||||
|
setInterval(() => {
|
||||||
|
const now = Date.now()
|
||||||
|
touched.update((t) => t.filter((e) => now - e.ts < TOUCHED_PULSE_MS))
|
||||||
|
healthDiffs.update((d) => d.filter((e) => now - e.ts < HEALTH_DIFF_MS))
|
||||||
|
}, 1000)
|
||||||
@@ -21,6 +21,16 @@ export function relativeTime(iso: string | null | undefined): string {
|
|||||||
return `${d}d ago`;
|
return `${d}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// debounce wraps fn so rapid calls (e.g. keystrokes in a filter input)
|
||||||
|
// collapse into one invocation after `wait`ms of silence.
|
||||||
|
export function debounce<T extends (...args: never[]) => void>(fn: T, wait = 300): T {
|
||||||
|
let timer: ReturnType<typeof setTimeout> | undefined;
|
||||||
|
return ((...args: Parameters<T>) => {
|
||||||
|
clearTimeout(timer);
|
||||||
|
timer = setTimeout(() => fn(...args), wait);
|
||||||
|
}) as T;
|
||||||
|
}
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
export type WithoutChild<T> = T extends { child?: any } ? Omit<T, "child"> : T;
|
||||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||||
|
|||||||
@@ -1,134 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount, onDestroy } from 'svelte'
|
|
||||||
import { fetchAgentActivity, type AgentActivity } from '$lib/api'
|
|
||||||
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
|
||||||
import * as Table from '$lib/components/ui/table'
|
|
||||||
import { Badge } from '$lib/components/ui/badge'
|
|
||||||
import { Input } from '$lib/components/ui/input'
|
|
||||||
import * as Select from '$lib/components/ui/select'
|
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
|
||||||
|
|
||||||
let activities = $state<AgentActivity[]>([])
|
|
||||||
let typeFilter = $state('all')
|
|
||||||
let agentFilter = $state('')
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
activities = await fetchAgentActivity({
|
|
||||||
activity_type: typeFilter !== 'all' ? typeFilter : undefined,
|
|
||||||
agent_id: agentFilter || undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
load()
|
|
||||||
const unsubscribe = subscribeEvents()
|
|
||||||
const interval = setInterval(load, 5000)
|
|
||||||
return () => {
|
|
||||||
unsubscribe()
|
|
||||||
clearInterval(interval)
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
||||||
$effect(() => {
|
|
||||||
const ev = $liveEvents[0]
|
|
||||||
if (!ev) return
|
|
||||||
if (ev.type.startsWith('execution.') || ev.type.startsWith('approval.')) load()
|
|
||||||
})
|
|
||||||
|
|
||||||
function typeVariant(type: string): 'default' | 'secondary' | 'outline' {
|
|
||||||
if (type === 'tool_call') return 'default'
|
|
||||||
if (type === 'decision') return 'secondary'
|
|
||||||
if (type === 'escalation') return 'secondary'
|
|
||||||
return 'outline'
|
|
||||||
}
|
|
||||||
|
|
||||||
function successVariant(success?: boolean | null): 'default' | 'destructive' | 'outline' {
|
|
||||||
if (success === true) return 'default'
|
|
||||||
if (success === false) return 'destructive'
|
|
||||||
return 'outline'
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-lg font-semibold">Agent activity</h1>
|
|
||||||
<span class="text-xs text-muted-foreground">{activities.length} entries</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Input placeholder="Filter by agent_id…" bind:value={agentFilter} class="max-w-xs" onchange={load} />
|
|
||||||
<Select.Root type="single" bind:value={typeFilter} onvalueChange={() => load()}>
|
|
||||||
<Select.Trigger class="w-40">
|
|
||||||
{typeFilter === 'all' ? 'All types' : typeFilter}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
<Select.Item value="all">All types</Select.Item>
|
|
||||||
<Select.Item value="tool_call">Tool call</Select.Item>
|
|
||||||
<Select.Item value="reasoning">Reasoning</Select.Item>
|
|
||||||
<Select.Item value="decision">Decision</Select.Item>
|
|
||||||
<Select.Item value="mcp_query">MCP query</Select.Item>
|
|
||||||
<Select.Item value="escalation">Escalation</Select.Item>
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
<button type="button" class="rounded-md border px-3 py-1.5 text-xs" onclick={load}>Refresh</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-hidden rounded-md border">
|
|
||||||
<ScrollArea class="h-full">
|
|
||||||
<Table.Root>
|
|
||||||
<Table.Header>
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Head class="w-36">Time</Table.Head>
|
|
||||||
<Table.Head>Agent</Table.Head>
|
|
||||||
<Table.Head class="w-24">Type</Table.Head>
|
|
||||||
<Table.Head>Tool / Entity</Table.Head>
|
|
||||||
<Table.Head>Summary</Table.Head>
|
|
||||||
<Table.Head class="w-16">Status</Table.Head>
|
|
||||||
<Table.Head class="w-20">Duration</Table.Head>
|
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{#each activities as a (a.id)}
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell class="font-mono text-xs text-muted-foreground"
|
|
||||||
>{new Date(a.ts).toLocaleString()}</Table.Cell
|
|
||||||
>
|
|
||||||
<Table.Cell class="font-mono text-xs">{a.agent_id}</Table.Cell>
|
|
||||||
<Table.Cell><Badge variant={typeVariant(a.activity_type)}>{a.activity_type}</Badge></Table.Cell>
|
|
||||||
<Table.Cell class="text-xs">
|
|
||||||
{#if a.tool_name}
|
|
||||||
<span class="font-mono">{a.tool_name}</span>
|
|
||||||
{:else if a.entity_id}
|
|
||||||
<span class="font-mono text-muted-foreground">{a.entity_id}</span>
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted-foreground">—</span>
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="max-w-64 truncate text-xs text-muted-foreground"
|
|
||||||
>{a.input_summary ?? a.output_summary ?? '—'}</Table.Cell
|
|
||||||
>
|
|
||||||
<Table.Cell>
|
|
||||||
{#if a.success !== undefined && a.success !== null}
|
|
||||||
<Badge variant={successVariant(a.success)}>{a.success ? 'ok' : 'fail'}</Badge>
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted-foreground">—</span>
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="text-xs text-muted-foreground">
|
|
||||||
{#if a.duration_ms}
|
|
||||||
{(a.duration_ms / 1000).toFixed(1)}s
|
|
||||||
{:else}
|
|
||||||
—
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{:else}
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell colspan={7} class="text-center text-muted-foreground">No agent activity yet.</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,131 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte'
|
|
||||||
import { fetchAudit, type AuditEntry } from '$lib/api'
|
|
||||||
import * as Table from '$lib/components/ui/table'
|
|
||||||
import { Badge } from '$lib/components/ui/badge'
|
|
||||||
import { Input } from '$lib/components/ui/input'
|
|
||||||
import * as Select from '$lib/components/ui/select'
|
|
||||||
import { Button } from '$lib/components/ui/button'
|
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
|
||||||
|
|
||||||
let entries = $state<AuditEntry[]>([])
|
|
||||||
let actorFilter = $state('all')
|
|
||||||
let actionFilter = $state('')
|
|
||||||
let entityFilter = $state('')
|
|
||||||
|
|
||||||
async function load() {
|
|
||||||
entries = await fetchAudit({
|
|
||||||
actor_type: actorFilter !== 'all' ? actorFilter : undefined,
|
|
||||||
action: actionFilter || undefined,
|
|
||||||
entity_id: entityFilter || undefined
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
load()
|
|
||||||
const interval = setInterval(load, 30000)
|
|
||||||
return () => clearInterval(interval)
|
|
||||||
})
|
|
||||||
|
|
||||||
function actorVariant(actor: string): 'default' | 'secondary' | 'outline' {
|
|
||||||
if (actor === 'agent') return 'secondary'
|
|
||||||
if (actor === 'operator') return 'default'
|
|
||||||
if (actor === 'scheduler') return 'outline'
|
|
||||||
return 'outline'
|
|
||||||
}
|
|
||||||
|
|
||||||
function methodBadge(method?: string | null): string {
|
|
||||||
if (!method) return ''
|
|
||||||
if (method === 'GET' || method === 'POST' || method === 'PATCH' || method === 'DELETE') return method
|
|
||||||
return ''
|
|
||||||
}
|
|
||||||
|
|
||||||
function statusVariant(code?: number | null): 'default' | 'destructive' | 'secondary' | 'outline' {
|
|
||||||
if (!code) return 'outline'
|
|
||||||
if (code >= 200 && code < 300) return 'default'
|
|
||||||
if (code >= 400 && code < 500) return 'secondary'
|
|
||||||
if (code >= 500) return 'destructive'
|
|
||||||
return 'outline'
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-lg font-semibold">Audit trail</h1>
|
|
||||||
<span class="text-xs text-muted-foreground">{entries.length} entries</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex gap-2">
|
|
||||||
<Select.Root type="single" bind:value={actorFilter} onvalueChange={() => load()}>
|
|
||||||
<Select.Trigger class="w-36">
|
|
||||||
{actorFilter === 'all' ? 'All actors' : actorFilter}
|
|
||||||
</Select.Trigger>
|
|
||||||
<Select.Content>
|
|
||||||
<Select.Item value="all">All actors</Select.Item>
|
|
||||||
<Select.Item value="agent">Agent</Select.Item>
|
|
||||||
<Select.Item value="operator">Operator</Select.Item>
|
|
||||||
<Select.Item value="system">System</Select.Item>
|
|
||||||
<Select.Item value="scheduler">Scheduler</Select.Item>
|
|
||||||
</Select.Content>
|
|
||||||
</Select.Root>
|
|
||||||
<Input placeholder="Action…" bind:value={actionFilter} class="max-w-32" onchange={load} />
|
|
||||||
<Input placeholder="Entity…" bind:value={entityFilter} class="max-w-48" onchange={load} />
|
|
||||||
<Button variant="outline" onclick={load}>Refresh</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-hidden rounded-md border">
|
|
||||||
<ScrollArea class="h-full">
|
|
||||||
<Table.Root>
|
|
||||||
<Table.Header>
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Head class="w-36">Time</Table.Head>
|
|
||||||
<Table.Head class="w-24">Actor</Table.Head>
|
|
||||||
<Table.Head>Action</Table.Head>
|
|
||||||
<Table.Head>Entity</Table.Head>
|
|
||||||
<Table.Head class="w-20">Method</Table.Head>
|
|
||||||
<Table.Head class="w-16">Code</Table.Head>
|
|
||||||
<Table.Head>Correlation</Table.Head>
|
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{#each entries as entry (entry.id)}
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell class="font-mono text-xs text-muted-foreground"
|
|
||||||
>{new Date(entry.ts).toLocaleString()}</Table.Cell
|
|
||||||
>
|
|
||||||
<Table.Cell>
|
|
||||||
<div class="flex flex-col gap-0.5">
|
|
||||||
<Badge variant={actorVariant(entry.actor_type)}>{entry.actor_type}</Badge>
|
|
||||||
{#if entry.actor_id}
|
|
||||||
<span class="font-mono text-xs text-muted-foreground">{entry.actor_id}</span>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="text-xs">{entry.action}</Table.Cell>
|
|
||||||
<Table.Cell class="font-mono text-xs text-muted-foreground">{entry.entity_id ?? '—'}</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
{#if methodBadge(entry.method)}
|
|
||||||
<Badge variant="outline">{methodBadge(entry.method)}</Badge>
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted-foreground">—</span>
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell>
|
|
||||||
{#if entry.status_code}
|
|
||||||
<Badge variant={statusVariant(entry.status_code)}>{entry.status_code}</Badge>
|
|
||||||
{:else}
|
|
||||||
<span class="text-muted-foreground">—</span>
|
|
||||||
{/if}
|
|
||||||
</Table.Cell>
|
|
||||||
<Table.Cell class="font-mono text-xs text-muted-foreground">{entry.correlation_id ?? '—'}</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{:else}
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell colspan={7} class="text-center text-muted-foreground">No audit entries.</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -1,8 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
|
import { messages, streaming, sendMessage, cancelStream, error } from '$lib/stores/chat'
|
||||||
import SessionRail from '$lib/components/SessionRail.svelte'
|
import SessionRail from '$lib/components/SessionRail.svelte'
|
||||||
import SessionGraph from '$lib/components/SessionGraph.svelte'
|
import TaskContextPanel from '$lib/components/TaskContextPanel.svelte'
|
||||||
import SessionDigest from '$lib/components/SessionDigest.svelte'
|
|
||||||
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
|
import ToolCallGroup from '$lib/components/ToolCallGroup.svelte'
|
||||||
import InlineApproval from '$lib/components/InlineApproval.svelte'
|
import InlineApproval from '$lib/components/InlineApproval.svelte'
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
@@ -181,7 +180,7 @@
|
|||||||
type="button"
|
type="button"
|
||||||
class="group/rz relative w-1.5 shrink-0 cursor-col-resize touch-none"
|
class="group/rz relative w-1.5 shrink-0 cursor-col-resize touch-none"
|
||||||
onpointerdown={startResize}
|
onpointerdown={startResize}
|
||||||
aria-label="Resize session graph"
|
aria-label="Resize task panel"
|
||||||
>
|
>
|
||||||
<span
|
<span
|
||||||
class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors {resizing
|
class="absolute inset-y-0 left-1/2 w-px -translate-x-1/2 transition-colors {resizing
|
||||||
@@ -190,10 +189,7 @@
|
|||||||
></span>
|
></span>
|
||||||
</button>
|
</button>
|
||||||
<div class="flex min-w-0 flex-1 flex-col">
|
<div class="flex min-w-0 flex-1 flex-col">
|
||||||
<SessionDigest />
|
<TaskContextPanel />
|
||||||
<div class="min-h-0 flex-1">
|
|
||||||
<SessionGraph />
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|||||||
@@ -44,13 +44,14 @@
|
|||||||
|
|
||||||
const types = $derived(Array.from(new Set(entities.map((e) => e.type))).sort())
|
const types = $derived(Array.from(new Set(entities.map((e) => e.type))).sort())
|
||||||
|
|
||||||
const filtered = $derived(
|
const filtered = $derived.by(() => {
|
||||||
entities.filter((e) => {
|
const q = query.trim().toLowerCase()
|
||||||
|
return entities.filter((e) => {
|
||||||
if (typeFilter !== 'all' && e.type !== typeFilter) return false
|
if (typeFilter !== 'all' && e.type !== typeFilter) return false
|
||||||
if (query && !e.slug.includes(query) && !e.name.includes(query)) return false
|
if (q && !e.slug.toLowerCase().includes(q) && !e.name.toLowerCase().includes(q)) return false
|
||||||
return true
|
return true
|
||||||
})
|
})
|
||||||
)
|
})
|
||||||
|
|
||||||
function stateVariant(state?: string | null): 'default' | 'secondary' | 'outline' {
|
function stateVariant(state?: string | null): 'default' | 'secondary' | 'outline' {
|
||||||
if (!state) return 'outline'
|
if (!state) return 'outline'
|
||||||
@@ -116,7 +117,10 @@
|
|||||||
{#each filtered as entity (entity.id)}
|
{#each filtered as entity (entity.id)}
|
||||||
<Table.Row
|
<Table.Row
|
||||||
class="cursor-pointer"
|
class="cursor-pointer"
|
||||||
|
role="button"
|
||||||
|
tabindex={0}
|
||||||
onclick={() => openEntity(entity.slug)}
|
onclick={() => openEntity(entity.slug)}
|
||||||
|
onkeydown={(e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); openEntity(entity.slug) } }}
|
||||||
>
|
>
|
||||||
<Table.Cell class="font-mono text-xs">{entity.slug}</Table.Cell>
|
<Table.Cell class="font-mono text-xs">{entity.slug}</Table.Cell>
|
||||||
<Table.Cell><Badge variant="outline">{entity.type}</Badge></Table.Cell>
|
<Table.Cell><Badge variant="outline">{entity.type}</Badge></Table.Cell>
|
||||||
|
|||||||
@@ -1,177 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { onMount } from 'svelte'
|
|
||||||
import { fetchEvents } from '$lib/api'
|
|
||||||
import { liveEvents, connectionState, subscribeEvents, type OikosEvent } from '$lib/stores/events'
|
|
||||||
import * as Table from '$lib/components/ui/table'
|
|
||||||
import { Badge } from '$lib/components/ui/badge'
|
|
||||||
import { Input } from '$lib/components/ui/input'
|
|
||||||
import { Button } from '$lib/components/ui/button'
|
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
|
||||||
import ChevronDownIcon from '@lucide/svelte/icons/chevron-down'
|
|
||||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right'
|
|
||||||
|
|
||||||
let history = $state<OikosEvent[]>([])
|
|
||||||
let paused = $state(false)
|
|
||||||
let typeFilter = $state('')
|
|
||||||
let severityFilter = $state('')
|
|
||||||
let groupByCorrelation = $state(false)
|
|
||||||
let expandedCorrelations = $state<Set<string>>(new Set())
|
|
||||||
|
|
||||||
async function loadHistory() {
|
|
||||||
history = await fetchEvents({ type: typeFilter || undefined, severity: severityFilter || undefined })
|
|
||||||
}
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
loadHistory()
|
|
||||||
const unsubscribe = subscribeEvents()
|
|
||||||
return unsubscribe
|
|
||||||
})
|
|
||||||
|
|
||||||
const feed = $derived.by(() => {
|
|
||||||
if (paused) return history
|
|
||||||
const seen = new Set(history.map((e) => e.id))
|
|
||||||
const merged = [...$liveEvents.filter((e) => !seen.has(e.id)), ...history]
|
|
||||||
return merged
|
|
||||||
.filter((e) => (!typeFilter || e.type.startsWith(typeFilter)) && (!severityFilter || e.severity === severityFilter))
|
|
||||||
.slice(0, 300)
|
|
||||||
})
|
|
||||||
|
|
||||||
const clustered = $derived.by(() => {
|
|
||||||
if (!groupByCorrelation) return null
|
|
||||||
const groups: { corr: string | null; events: OikosEvent[]; latest: number }[] = []
|
|
||||||
const seen = new Map<string | null, OikosEvent[]>()
|
|
||||||
for (const ev of feed) {
|
|
||||||
const key = ev.correlation_id ?? null
|
|
||||||
if (!seen.has(key)) seen.set(key, [])
|
|
||||||
seen.get(key)!.push(ev)
|
|
||||||
}
|
|
||||||
for (const [corr, events] of seen) {
|
|
||||||
groups.push({ corr, events, latest: Math.max(...events.map((e) => e.id)) })
|
|
||||||
}
|
|
||||||
groups.sort((a, b) => b.latest - a.latest)
|
|
||||||
return groups
|
|
||||||
})
|
|
||||||
|
|
||||||
function toggleCorrelation(corr: string | null) {
|
|
||||||
const key = corr ?? '__none'
|
|
||||||
expandedCorrelations = new Set(expandedCorrelations)
|
|
||||||
if (expandedCorrelations.has(key)) {
|
|
||||||
expandedCorrelations.delete(key)
|
|
||||||
} else {
|
|
||||||
expandedCorrelations.add(key)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function correlationLabel(corr: string | null): string {
|
|
||||||
if (!corr) return 'ungrouped'
|
|
||||||
return corr.slice(0, 12)
|
|
||||||
}
|
|
||||||
|
|
||||||
function severityVariant(sev: string): 'default' | 'secondary' | 'destructive' {
|
|
||||||
if (sev === 'critical') return 'destructive'
|
|
||||||
if (sev === 'warning') return 'secondary'
|
|
||||||
return 'default'
|
|
||||||
}
|
|
||||||
|
|
||||||
function mostSevere(events: OikosEvent[]): 'info' | 'warning' | 'critical' {
|
|
||||||
if (events.some((e) => e.severity === 'critical')) return 'critical'
|
|
||||||
if (events.some((e) => e.severity === 'warning')) return 'warning'
|
|
||||||
return 'info'
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="flex h-full flex-col gap-4 p-4 md:p-6">
|
|
||||||
<div class="flex items-center justify-between">
|
|
||||||
<h1 class="text-lg font-semibold">Live event feed</h1>
|
|
||||||
<span class="text-xs text-muted-foreground">
|
|
||||||
stream: {$connectionState}
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex items-center gap-2">
|
|
||||||
<Input placeholder="Type prefix (e.g. entity.)" bind:value={typeFilter} class="max-w-xs" onchange={loadHistory} />
|
|
||||||
<Input placeholder="Severity" bind:value={severityFilter} class="max-w-32" onchange={loadHistory} />
|
|
||||||
<Button variant={paused ? 'default' : 'outline'} onclick={() => (paused = !paused)}>
|
|
||||||
{paused ? 'Resume' : 'Pause'}
|
|
||||||
</Button>
|
|
||||||
<Button variant={groupByCorrelation ? 'default' : 'outline'} onclick={() => (groupByCorrelation = !groupByCorrelation)}>
|
|
||||||
Groups
|
|
||||||
</Button>
|
|
||||||
<Button variant="outline" onclick={loadHistory}>Refresh</Button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div class="flex-1 overflow-hidden rounded-md border">
|
|
||||||
<ScrollArea class="h-full">
|
|
||||||
{#if groupByCorrelation && clustered}
|
|
||||||
<div class="flex flex-col">
|
|
||||||
{#each clustered as group (group.corr ?? '__none')}
|
|
||||||
{@const key = group.corr ?? '__none'}
|
|
||||||
{@const isExpanded = expandedCorrelations.has(key)}
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
class="flex items-center gap-2 border-b px-4 py-2 text-left text-xs hover:bg-muted/50"
|
|
||||||
onclick={() => toggleCorrelation(group.corr)}
|
|
||||||
>
|
|
||||||
{#if isExpanded}
|
|
||||||
<ChevronDownIcon class="size-3 text-muted-foreground" />
|
|
||||||
{:else}
|
|
||||||
<ChevronRightIcon class="size-3 text-muted-foreground" />
|
|
||||||
{/if}
|
|
||||||
<Badge variant={severityVariant(mostSevere(group.events))} class="shrink-0"
|
|
||||||
>{mostSevere(group.events)}</Badge
|
|
||||||
>
|
|
||||||
<span class="font-mono">{correlationLabel(group.corr)}</span>
|
|
||||||
<span class="text-muted-foreground">{group.events.length} events</span>
|
|
||||||
<span class="truncate text-muted-foreground">{group.events[0]?.type ?? ''}</span>
|
|
||||||
<span class="grow"></span>
|
|
||||||
<span class="text-muted-foreground">{new Date(group.events[0]?.ts ?? '').toLocaleTimeString()}</span>
|
|
||||||
</button>
|
|
||||||
{#if isExpanded}
|
|
||||||
{#each group.events as ev (ev.id)}
|
|
||||||
<div class="flex items-center gap-3 border-b py-1 pl-10 pr-4 text-xs">
|
|
||||||
<span class="w-20 shrink-0 font-mono text-muted-foreground"
|
|
||||||
>{new Date(ev.ts).toLocaleTimeString()}</span
|
|
||||||
>
|
|
||||||
<Badge variant={severityVariant(ev.severity)} class="shrink-0">{ev.severity}</Badge>
|
|
||||||
<span class="font-mono">{ev.type}</span>
|
|
||||||
<span class="truncate text-muted-foreground">{ev.source}</span>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
{/if}
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<Table.Root>
|
|
||||||
<Table.Header>
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Head class="w-32">Time</Table.Head>
|
|
||||||
<Table.Head class="w-24">Severity</Table.Head>
|
|
||||||
<Table.Head>Type</Table.Head>
|
|
||||||
<Table.Head>Source</Table.Head>
|
|
||||||
<Table.Head>Correlation</Table.Head>
|
|
||||||
</Table.Row>
|
|
||||||
</Table.Header>
|
|
||||||
<Table.Body>
|
|
||||||
{#each feed as ev (ev.id)}
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell class="font-mono text-xs text-muted-foreground"
|
|
||||||
>{new Date(ev.ts).toLocaleTimeString()}</Table.Cell
|
|
||||||
>
|
|
||||||
<Table.Cell><Badge variant={severityVariant(ev.severity)}>{ev.severity}</Badge></Table.Cell>
|
|
||||||
<Table.Cell class="font-mono text-xs">{ev.type}</Table.Cell>
|
|
||||||
<Table.Cell class="text-xs text-muted-foreground">{ev.source}</Table.Cell>
|
|
||||||
<Table.Cell class="font-mono text-xs text-muted-foreground"
|
|
||||||
>{ev.correlation_id ?? '—'}</Table.Cell
|
|
||||||
>
|
|
||||||
</Table.Row>
|
|
||||||
{:else}
|
|
||||||
<Table.Row>
|
|
||||||
<Table.Cell colspan={5} class="text-center text-muted-foreground">No events yet.</Table.Cell>
|
|
||||||
</Table.Row>
|
|
||||||
{/each}
|
|
||||||
</Table.Body>
|
|
||||||
</Table.Root>
|
|
||||||
{/if}
|
|
||||||
</ScrollArea>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
@@ -8,6 +8,7 @@
|
|||||||
import { Badge } from '$lib/components/ui/badge'
|
import { Badge } from '$lib/components/ui/badge'
|
||||||
import * as Sheet from '$lib/components/ui/sheet'
|
import * as Sheet from '$lib/components/ui/sheet'
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||||
|
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
||||||
import LocateFixedIcon from '@lucide/svelte/icons/locate-fixed'
|
import LocateFixedIcon from '@lucide/svelte/icons/locate-fixed'
|
||||||
|
|
||||||
interface Node extends Entity {
|
interface Node extends Entity {
|
||||||
@@ -289,6 +290,14 @@
|
|||||||
search = ''
|
search = ''
|
||||||
load()
|
load()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let entitySheetOpen = $state(false)
|
||||||
|
let entitySheetSlug = $state<string | null>(null)
|
||||||
|
|
||||||
|
function openEntityDetail(slug: string) {
|
||||||
|
entitySheetSlug = slug
|
||||||
|
entitySheetOpen = true
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="flex h-full flex-col gap-3 p-4">
|
<div class="flex h-full flex-col gap-3 p-4">
|
||||||
@@ -461,7 +470,7 @@
|
|||||||
</Sheet.Header>
|
</Sheet.Header>
|
||||||
<div class="flex flex-col gap-4 overflow-y-auto px-4 pb-4">
|
<div class="flex flex-col gap-4 overflow-y-auto px-4 pb-4">
|
||||||
<div class="flex gap-2">
|
<div class="flex gap-2">
|
||||||
<Button variant="outline" size="sm" onclick={() => (location.hash = '#/entity/' + encodeURIComponent(selected!.slug))}>
|
<Button variant="outline" size="sm" onclick={() => openEntityDetail(selected!.slug)}>
|
||||||
View entity detail
|
View entity detail
|
||||||
</Button>
|
</Button>
|
||||||
<Button variant="outline" size="sm" onclick={() => rerootTo(selected as Node)}>Re-root here</Button>
|
<Button variant="outline" size="sm" onclick={() => rerootTo(selected as Node)}>Re-root here</Button>
|
||||||
@@ -504,3 +513,5 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</Sheet.Content>
|
</Sheet.Content>
|
||||||
</Sheet.Root>
|
</Sheet.Root>
|
||||||
|
|
||||||
|
<EntitySheet slug={entitySheetSlug} bind:open={entitySheetOpen} />
|
||||||
|
|||||||
@@ -1,10 +1,12 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
|
import DOMPurify from 'dompurify'
|
||||||
import { searchKnowledge, fetchRecentKnowledge, type KnowledgeHit, type RecentKnowledge, type KnowledgeItem } from '$lib/api'
|
import { searchKnowledge, fetchRecentKnowledge, type KnowledgeHit, type RecentKnowledge, type KnowledgeItem } from '$lib/api'
|
||||||
import * as Card from '$lib/components/ui/card'
|
import * as Card from '$lib/components/ui/card'
|
||||||
import { Badge } from '$lib/components/ui/badge'
|
import { Badge } from '$lib/components/ui/badge'
|
||||||
import { Input } from '$lib/components/ui/input'
|
import { Input } from '$lib/components/ui/input'
|
||||||
import { Button } from '$lib/components/ui/button'
|
import { Button } from '$lib/components/ui/button'
|
||||||
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
import { ScrollArea } from '$lib/components/ui/scroll-area'
|
||||||
|
import EntitySheet from '$lib/components/EntitySheet.svelte'
|
||||||
import SearchIcon from '@lucide/svelte/icons/search'
|
import SearchIcon from '@lucide/svelte/icons/search'
|
||||||
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
import SparklesIcon from '@lucide/svelte/icons/sparkles'
|
||||||
import BotIcon from '@lucide/svelte/icons/bot'
|
import BotIcon from '@lucide/svelte/icons/bot'
|
||||||
@@ -54,8 +56,12 @@
|
|||||||
return `${Math.floor(s / 86400)}d ago`
|
return `${Math.floor(s / 86400)}d ago`
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let sheetOpen = $state(false)
|
||||||
|
let selectedSlug = $state<string | null>(null)
|
||||||
|
|
||||||
function openEntity(slug: string) {
|
function openEntity(slug: string) {
|
||||||
location.hash = '#/entity/' + encodeURIComponent(slug)
|
selectedSlug = slug
|
||||||
|
sheetOpen = true
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -109,7 +115,7 @@
|
|||||||
<p class="text-sm text-muted-foreground">{results.length} result{results.length === 1 ? '' : 's'} for "{query}"</p>
|
<p class="text-sm text-muted-foreground">{results.length} result{results.length === 1 ? '' : 's'} for "{query}"</p>
|
||||||
<ScrollArea class="flex-1">
|
<ScrollArea class="flex-1">
|
||||||
<div class="flex flex-col gap-3 pr-4">
|
<div class="flex flex-col gap-3 pr-4">
|
||||||
{#each results as hit (hit.id)}
|
{#each results as hit (hit.slug)}
|
||||||
<Card.Root class="transition-colors hover:bg-muted/50">
|
<Card.Root class="transition-colors hover:bg-muted/50">
|
||||||
<Card.Header>
|
<Card.Header>
|
||||||
<div class="flex items-center gap-2">
|
<div class="flex items-center gap-2">
|
||||||
@@ -117,8 +123,10 @@
|
|||||||
<Badge variant={typeVariant(hit.type)}>{hit.type}</Badge>
|
<Badge variant={typeVariant(hit.type)}>{hit.type}</Badge>
|
||||||
</div>
|
</div>
|
||||||
{#if hit.snippet}
|
{#if hit.snippet}
|
||||||
<!-- eslint-disable-next-line svelte/no-at-html-tags — server-sanitized ts_headline -->
|
<!-- eslint-disable-next-line svelte/no-at-html-tags — sanitized below, ts_headline only ever emits <b> -->
|
||||||
<Card.Description class="text-xs">{@html hit.snippet}</Card.Description>
|
<Card.Description class="text-xs"
|
||||||
|
>{@html DOMPurify.sanitize(hit.snippet, { ALLOWED_TAGS: ['b'], ALLOWED_ATTR: [] })}</Card.Description
|
||||||
|
>
|
||||||
{/if}
|
{/if}
|
||||||
{#if hit.linked_entities?.length}
|
{#if hit.linked_entities?.length}
|
||||||
<div class="mt-1 flex flex-wrap gap-1">
|
<div class="mt-1 flex flex-wrap gap-1">
|
||||||
@@ -174,3 +182,5 @@
|
|||||||
</ScrollArea>
|
</ScrollArea>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<EntitySheet slug={selectedSlug} bind:open={sheetOpen} />
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onMount } from 'svelte'
|
import { onMount } from 'svelte'
|
||||||
import { fetchDashboardSummary, type DashboardSummary } from '$lib/api'
|
import { fetchDashboardSummary, type DashboardSummary } from '$lib/api'
|
||||||
import { liveEvents, subscribeEvents, type OikosEvent } from '$lib/stores/events'
|
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||||
import * as Card from '$lib/components/ui/card'
|
import * as Card from '$lib/components/ui/card'
|
||||||
import { Badge } from '$lib/components/ui/badge'
|
import { Badge } from '$lib/components/ui/badge'
|
||||||
import { Skeleton } from '$lib/components/ui/skeleton'
|
import { Skeleton } from '$lib/components/ui/skeleton'
|
||||||
@@ -47,10 +47,6 @@
|
|||||||
summary?.event_rate.length ? Math.max(...summary.event_rate.map((b) => b.count), 1) : 1
|
summary?.event_rate.length ? Math.max(...summary.event_rate.map((b) => b.count), 1) : 1
|
||||||
)
|
)
|
||||||
|
|
||||||
function formatEventLabel(ev: OikosEvent) {
|
|
||||||
return ev.type
|
|
||||||
}
|
|
||||||
|
|
||||||
const totalEntities = $derived(
|
const totalEntities = $derived(
|
||||||
summary ? Object.values(summary.entities_by_type).reduce((a, b) => a + b, 0) : 0
|
summary ? Object.values(summary.entities_by_type).reduce((a, b) => a + b, 0) : 0
|
||||||
)
|
)
|
||||||
@@ -144,53 +140,57 @@
|
|||||||
</Card.Footer>
|
</Card.Footer>
|
||||||
</Card.Root>
|
</Card.Root>
|
||||||
|
|
||||||
<Card.Root class="@container/card">
|
<button type="button" class="text-left" onclick={() => (location.hash = '#/signals')}>
|
||||||
<Card.Header>
|
<Card.Root class="@container/card transition-colors hover:border-primary/50">
|
||||||
<Card.Description>Open signals</Card.Description>
|
<Card.Header>
|
||||||
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
<Card.Description>Open signals</Card.Description>
|
||||||
{totalSignals}
|
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||||
</Card.Title>
|
{totalSignals}
|
||||||
<Card.Action>
|
</Card.Title>
|
||||||
{#if worstSeverity === 'critical'}
|
<Card.Action>
|
||||||
<Badge variant="destructive"><TriangleAlertIcon />critical</Badge>
|
{#if worstSeverity === 'critical'}
|
||||||
{:else if worstSeverity === 'warning'}
|
<Badge variant="destructive"><TriangleAlertIcon />critical</Badge>
|
||||||
<Badge variant="secondary"><TriangleAlertIcon />warning</Badge>
|
{:else if worstSeverity === 'warning'}
|
||||||
{:else}
|
<Badge variant="secondary"><TriangleAlertIcon />warning</Badge>
|
||||||
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
|
{:else}
|
||||||
{/if}
|
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
|
||||||
</Card.Action>
|
{/if}
|
||||||
</Card.Header>
|
</Card.Action>
|
||||||
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
|
</Card.Header>
|
||||||
<div class="line-clamp-1 flex flex-wrap gap-x-1.5 font-medium">
|
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
|
||||||
{#each Object.entries(summary.signals_by_severity) as [severity, count]}
|
<div class="line-clamp-1 flex flex-wrap gap-x-1.5 font-medium">
|
||||||
<span class="text-muted-foreground">{severity}: <span class="text-foreground">{count}</span></span>
|
{#each Object.entries(summary.signals_by_severity) as [severity, count]}
|
||||||
{/each}
|
<span class="text-muted-foreground">{severity}: <span class="text-foreground">{count}</span></span>
|
||||||
</div>
|
{/each}
|
||||||
<div class="text-muted-foreground">Unresolved right now</div>
|
</div>
|
||||||
</Card.Footer>
|
<div class="text-muted-foreground">Unresolved right now</div>
|
||||||
</Card.Root>
|
</Card.Footer>
|
||||||
|
</Card.Root>
|
||||||
|
</button>
|
||||||
|
|
||||||
<Card.Root class="@container/card">
|
<button type="button" class="text-left" onclick={() => (location.hash = '#/ops')}>
|
||||||
<Card.Header>
|
<Card.Root class="@container/card transition-colors hover:border-primary/50">
|
||||||
<Card.Description>Pending approvals</Card.Description>
|
<Card.Header>
|
||||||
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
<Card.Description>Pending approvals</Card.Description>
|
||||||
{summary.approvals_pending}
|
<Card.Title class="text-2xl font-semibold tabular-nums @[250px]/card:text-3xl">
|
||||||
</Card.Title>
|
{summary.approvals_pending}
|
||||||
<Card.Action>
|
</Card.Title>
|
||||||
{#if summary.approvals_pending > 0}
|
<Card.Action>
|
||||||
<Badge variant="destructive">needs review</Badge>
|
{#if summary.approvals_pending > 0}
|
||||||
{:else}
|
<Badge variant="destructive">needs review</Badge>
|
||||||
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
|
{:else}
|
||||||
{/if}
|
<Badge variant="outline"><CircleCheckIcon class="text-success" />clear</Badge>
|
||||||
</Card.Action>
|
{/if}
|
||||||
</Card.Header>
|
</Card.Action>
|
||||||
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
|
</Card.Header>
|
||||||
<div class="line-clamp-1 flex gap-2 font-medium">
|
<Card.Footer class="flex-col items-start gap-1.5 text-sm">
|
||||||
{executionsRunning} running · {executionsFailed} failed
|
<div class="line-clamp-1 flex gap-2 font-medium">
|
||||||
</div>
|
{executionsRunning} running · {executionsFailed} failed
|
||||||
<div class="text-muted-foreground">Executions in the last 24h</div>
|
</div>
|
||||||
</Card.Footer>
|
<div class="text-muted-foreground">Executions in the last 24h</div>
|
||||||
</Card.Root>
|
</Card.Footer>
|
||||||
|
</Card.Root>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{#if degradedTypes.length}
|
{#if degradedTypes.length}
|
||||||
@@ -239,7 +239,7 @@
|
|||||||
>{ev.severity}</Badge
|
>{ev.severity}</Badge
|
||||||
>
|
>
|
||||||
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleTimeString()}</span>
|
<span class="font-mono text-muted-foreground">{new Date(ev.ts).toLocaleTimeString()}</span>
|
||||||
<span>{formatEventLabel(ev)}</span>
|
<span>{ev.type}</span>
|
||||||
<span class="truncate text-muted-foreground">{ev.source}</span>
|
<span class="truncate text-muted-foreground">{ev.source}</span>
|
||||||
</div>
|
</div>
|
||||||
{:else}
|
{:else}
|
||||||
|
|||||||
@@ -1,172 +0,0 @@
|
|||||||
<script lang="ts">
|
|
||||||
import { sessions, loadSessions, loadSessionMessages, deleteSession, currentSession } from '$lib/stores/chat'
|
|
||||||
import { onMount } from 'svelte'
|
|
||||||
import Trash2Icon from '@lucide/svelte/icons/trash-2'
|
|
||||||
|
|
||||||
onMount(() => {
|
|
||||||
loadSessions()
|
|
||||||
})
|
|
||||||
|
|
||||||
let confirmDelete = $state<string | null>(null)
|
|
||||||
|
|
||||||
function handleDelete(e: MouseEvent, id: string) {
|
|
||||||
e.stopPropagation()
|
|
||||||
if (confirmDelete === id) {
|
|
||||||
deleteSession(id)
|
|
||||||
confirmDelete = null
|
|
||||||
} else {
|
|
||||||
confirmDelete = id
|
|
||||||
setTimeout(() => { if (confirmDelete === id) confirmDelete = null }, 3000)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<div class="sessions-page">
|
|
||||||
<h2>Sessions</h2>
|
|
||||||
<div class="session-list">
|
|
||||||
{#each $sessions as session (session.id)}
|
|
||||||
<div
|
|
||||||
class="session-card group"
|
|
||||||
class:active={$currentSession === session.id}
|
|
||||||
>
|
|
||||||
<button
|
|
||||||
class="session-content"
|
|
||||||
onclick={() => {
|
|
||||||
loadSessionMessages(session.id)
|
|
||||||
location.hash = '#/chat'
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div class="session-title">{session.title || 'Untitled'}</div>
|
|
||||||
<div class="session-meta">
|
|
||||||
{new Date(session.last_active_at).toLocaleString()}
|
|
||||||
</div>
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
class="session-delete"
|
|
||||||
onclick={(e) => handleDelete(e, session.id)}
|
|
||||||
title={confirmDelete === session.id ? 'Click again to confirm delete' : 'Delete session'}
|
|
||||||
aria-label="Delete session"
|
|
||||||
>
|
|
||||||
{#if confirmDelete === session.id}
|
|
||||||
<span class="confirm-text">Delete?</span>
|
|
||||||
{:else}
|
|
||||||
<Trash2Icon class="icon" />
|
|
||||||
{/if}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<div class="empty">No sessions yet. Start chatting with Nomos.</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<style>
|
|
||||||
.sessions-page {
|
|
||||||
max-width: 720px;
|
|
||||||
margin: 0 auto;
|
|
||||||
padding: 2rem 1rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
font-size: 1.125rem;
|
|
||||||
font-weight: 600;
|
|
||||||
margin-bottom: 1rem;
|
|
||||||
color: var(--text);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-list {
|
|
||||||
display: flex;
|
|
||||||
flex-direction: column;
|
|
||||||
gap: 0.5rem;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-card {
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
background: var(--bg-surface);
|
|
||||||
border: 1px solid var(--border);
|
|
||||||
border-radius: 8px;
|
|
||||||
color: var(--text);
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: 0.875rem;
|
|
||||||
transition: border-color 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-card:hover {
|
|
||||||
border-color: var(--accent-blue);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-card.active {
|
|
||||||
border-color: var(--accent-blue);
|
|
||||||
background: var(--bg-hover);
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-content {
|
|
||||||
flex: 1;
|
|
||||||
display: flex;
|
|
||||||
justify-content: space-between;
|
|
||||||
align-items: center;
|
|
||||||
padding: 0.75rem 1rem;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
cursor: pointer;
|
|
||||||
color: inherit;
|
|
||||||
font-family: inherit;
|
|
||||||
font-size: inherit;
|
|
||||||
text-align: left;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-delete {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
justify-content: center;
|
|
||||||
width: 32px;
|
|
||||||
height: 32px;
|
|
||||||
margin-right: 4px;
|
|
||||||
padding: 0;
|
|
||||||
background: none;
|
|
||||||
border: none;
|
|
||||||
border-radius: 6px;
|
|
||||||
cursor: pointer;
|
|
||||||
color: var(--text-muted);
|
|
||||||
opacity: 0;
|
|
||||||
transition: opacity 0.15s, background 0.15s;
|
|
||||||
}
|
|
||||||
|
|
||||||
.group:hover .session-delete {
|
|
||||||
opacity: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-delete:hover {
|
|
||||||
background: var(--destructive-bg-subtle, rgba(239, 68, 68, 0.1));
|
|
||||||
color: var(--destructive, #ef4444);
|
|
||||||
}
|
|
||||||
|
|
||||||
.confirm-text {
|
|
||||||
font-size: 0.65rem;
|
|
||||||
font-weight: 700;
|
|
||||||
color: var(--destructive, #ef4444);
|
|
||||||
}
|
|
||||||
|
|
||||||
.icon {
|
|
||||||
width: 16px;
|
|
||||||
height: 16px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-title {
|
|
||||||
font-weight: 500;
|
|
||||||
}
|
|
||||||
|
|
||||||
.session-meta {
|
|
||||||
font-size: 0.75rem;
|
|
||||||
color: var(--text-muted);
|
|
||||||
}
|
|
||||||
|
|
||||||
.empty {
|
|
||||||
color: var(--text-muted);
|
|
||||||
font-size: 0.875rem;
|
|
||||||
padding: 2rem 0;
|
|
||||||
text-align: center;
|
|
||||||
}
|
|
||||||
</style>
|
|
||||||
213
web/src/pages/Tasks.svelte
Normal file
213
web/src/pages/Tasks.svelte
Normal file
@@ -0,0 +1,213 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { sessions, loadSessions, loadSessionMessages, deleteSession, newChat } from '$lib/stores/chat'
|
||||||
|
import { liveEvents, subscribeEvents } from '$lib/stores/events'
|
||||||
|
import type { Session } from '$lib/api'
|
||||||
|
import { relativeTime } from '$lib/utils'
|
||||||
|
import { Badge } from '$lib/components/ui/badge'
|
||||||
|
import { Button } from '$lib/components/ui/button'
|
||||||
|
import * as Card from '$lib/components/ui/card'
|
||||||
|
import PlusIcon from '@lucide/svelte/icons/plus'
|
||||||
|
import Trash2Icon from '@lucide/svelte/icons/trash-2'
|
||||||
|
import { onMount } from 'svelte'
|
||||||
|
|
||||||
|
// Live board: refetch when a task's lifecycle changes anywhere (the agent set
|
||||||
|
// a goal, advanced status, raised/answered a question, finished). We subscribe
|
||||||
|
// to the event store directly rather than via $effect so delivery is
|
||||||
|
// deterministic. We must scan ALL events newer than the last we saw, not just
|
||||||
|
// liveEvents[0]: entity.touched fires on every tool call, so a task.status
|
||||||
|
// event is usually buried below several touches by the time we're notified. A
|
||||||
|
// short debounce coalesces one task's goal.set + plan.proposed + task.status
|
||||||
|
// burst into a single refetch.
|
||||||
|
const TASK_EVENTS = new Set(['task.status', 'goal.set', 'question.raised', 'question.answered'])
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
loadSessions()
|
||||||
|
const unsubStream = subscribeEvents() // keep the global stream open while the board is up
|
||||||
|
let lastSeenId = 0
|
||||||
|
let refreshTimer: ReturnType<typeof setTimeout> | null = null
|
||||||
|
const unsub = liveEvents.subscribe((evs) => {
|
||||||
|
if (evs.length === 0) return
|
||||||
|
const maxId = evs[0].id // newest-first
|
||||||
|
if (maxId <= lastSeenId) return
|
||||||
|
const relevant = evs.some((e) => e.id > lastSeenId && TASK_EVENTS.has(e.type))
|
||||||
|
lastSeenId = maxId
|
||||||
|
if (relevant) {
|
||||||
|
if (refreshTimer) clearTimeout(refreshTimer)
|
||||||
|
refreshTimer = setTimeout(() => loadSessions(), 400)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
return () => {
|
||||||
|
unsub()
|
||||||
|
unsubStream()
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
// ── Status → display ────────────────────────────────────────────────
|
||||||
|
type Bucket = 'running' | 'input' | 'done' | 'failed'
|
||||||
|
function bucket(s: Session): Bucket {
|
||||||
|
switch (s.status) {
|
||||||
|
case 'awaiting_input':
|
||||||
|
return 'input'
|
||||||
|
case 'done':
|
||||||
|
return s.outcome === 'failure' ? 'failed' : 'done'
|
||||||
|
case 'failed':
|
||||||
|
return 'failed'
|
||||||
|
default:
|
||||||
|
return 'running' // active | planning | executing | undefined
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
interface StatusStyle {
|
||||||
|
label: string
|
||||||
|
dot: string
|
||||||
|
pulse: boolean
|
||||||
|
variant: 'default' | 'secondary' | 'destructive' | 'outline'
|
||||||
|
}
|
||||||
|
function statusStyle(s: Session): StatusStyle {
|
||||||
|
switch (bucket(s)) {
|
||||||
|
case 'input':
|
||||||
|
return { label: 'Needs input', dot: 'bg-warning', pulse: true, variant: 'secondary' }
|
||||||
|
case 'done':
|
||||||
|
return { label: s.outcome === 'partial' ? 'Done · partial' : 'Done', dot: 'bg-success', pulse: false, variant: 'default' }
|
||||||
|
case 'failed':
|
||||||
|
return { label: 'Failed', dot: 'bg-destructive', pulse: false, variant: 'destructive' }
|
||||||
|
default:
|
||||||
|
return { label: 'Running', dot: 'bg-primary', pulse: true, variant: 'secondary' }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const FILTERS: { id: 'all' | Bucket; label: string }[] = [
|
||||||
|
{ id: 'all', label: 'All' },
|
||||||
|
{ id: 'running', label: 'Running' },
|
||||||
|
{ id: 'input', label: 'Needs input' },
|
||||||
|
{ id: 'done', label: 'Done' },
|
||||||
|
{ id: 'failed', label: 'Failed' }
|
||||||
|
]
|
||||||
|
let filter = $state<'all' | Bucket>('all')
|
||||||
|
|
||||||
|
const counts = $derived.by(() => {
|
||||||
|
const c: Record<string, number> = { all: $sessions.length, running: 0, input: 0, done: 0, failed: 0 }
|
||||||
|
for (const s of $sessions) c[bucket(s)]++
|
||||||
|
return c
|
||||||
|
})
|
||||||
|
|
||||||
|
const visible = $derived(
|
||||||
|
filter === 'all' ? $sessions : $sessions.filter((s) => bucket(s) === filter)
|
||||||
|
)
|
||||||
|
|
||||||
|
function heading(s: Session): string {
|
||||||
|
return s.goal || s.title || 'Untitled task'
|
||||||
|
}
|
||||||
|
|
||||||
|
function openTask(id: string) {
|
||||||
|
loadSessionMessages(id)
|
||||||
|
location.hash = '#/chat'
|
||||||
|
}
|
||||||
|
|
||||||
|
function startTask() {
|
||||||
|
newChat()
|
||||||
|
location.hash = '#/chat'
|
||||||
|
}
|
||||||
|
|
||||||
|
let confirmDelete = $state<string | null>(null)
|
||||||
|
function handleDelete(e: MouseEvent, id: string) {
|
||||||
|
e.stopPropagation()
|
||||||
|
if (confirmDelete === id) {
|
||||||
|
deleteSession(id)
|
||||||
|
confirmDelete = null
|
||||||
|
} else {
|
||||||
|
confirmDelete = id
|
||||||
|
setTimeout(() => { if (confirmDelete === id) confirmDelete = null }, 3000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="mx-auto flex h-full min-h-0 max-w-6xl flex-col p-4 sm:p-6">
|
||||||
|
<div class="mb-4 flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 class="text-lg font-semibold">Tasks</h2>
|
||||||
|
<p class="text-sm text-muted-foreground">Every task is a goal Nomos works to completion.</p>
|
||||||
|
</div>
|
||||||
|
<Button onclick={startTask} class="gap-1.5">
|
||||||
|
<PlusIcon class="size-4" />
|
||||||
|
New task
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Filter chips -->
|
||||||
|
<div class="mb-4 flex flex-wrap gap-1.5">
|
||||||
|
{#each FILTERS as f}
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onclick={() => (filter = f.id)}
|
||||||
|
class="rounded-full border px-2.5 py-1 text-xs transition-colors {filter === f.id
|
||||||
|
? 'border-primary bg-primary/10 text-foreground'
|
||||||
|
: 'border-border text-muted-foreground hover:bg-muted/50'}"
|
||||||
|
>
|
||||||
|
{f.label}
|
||||||
|
<span class="ml-1 opacity-60">{counts[f.id] ?? 0}</span>
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="min-h-0 flex-1 overflow-y-auto">
|
||||||
|
{#if visible.length === 0}
|
||||||
|
<div class="flex flex-col items-center gap-4 pt-20 text-center">
|
||||||
|
<p class="max-w-sm text-sm text-muted-foreground">
|
||||||
|
{filter === 'all'
|
||||||
|
? 'No tasks yet. Start one and Nomos will plan it, execute it, and report the outcome.'
|
||||||
|
: `No ${FILTERS.find((f) => f.id === filter)?.label.toLowerCase()} tasks.`}
|
||||||
|
</p>
|
||||||
|
{#if filter === 'all'}
|
||||||
|
<Button onclick={startTask} variant="outline" class="gap-1.5">
|
||||||
|
<PlusIcon class="size-4" /> Start your first task
|
||||||
|
</Button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<div class="grid grid-cols-1 gap-3 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{#each visible as s (s.id)}
|
||||||
|
{@const st = statusStyle(s)}
|
||||||
|
<div class="group relative">
|
||||||
|
<button type="button" class="block w-full text-left" onclick={() => openTask(s.id)}>
|
||||||
|
<Card.Root class="h-full transition-colors hover:border-primary/50">
|
||||||
|
<Card.Header class="pb-2">
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<span class="flex items-center gap-1.5 text-xs font-medium text-muted-foreground">
|
||||||
|
<span class="size-2 rounded-full {st.dot} {st.pulse ? 'animate-pulse' : ''}"></span>
|
||||||
|
{st.label}
|
||||||
|
</span>
|
||||||
|
<span class="text-[11px] text-muted-foreground">{relativeTime(s.last_active_at)}</span>
|
||||||
|
</div>
|
||||||
|
<Card.Title class="line-clamp-2 text-sm leading-snug">{heading(s)}</Card.Title>
|
||||||
|
</Card.Header>
|
||||||
|
<Card.Content class="pt-0">
|
||||||
|
{#if s.summary}
|
||||||
|
<p class="line-clamp-3 text-xs text-muted-foreground">{s.summary}</p>
|
||||||
|
{:else if s.goal && s.title && s.goal !== s.title}
|
||||||
|
<p class="line-clamp-2 text-xs text-muted-foreground">{s.title}</p>
|
||||||
|
{:else}
|
||||||
|
<p class="text-xs italic text-muted-foreground/60">No summary yet.</p>
|
||||||
|
{/if}
|
||||||
|
</Card.Content>
|
||||||
|
</Card.Root>
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="absolute right-2 top-2 flex size-7 items-center justify-center rounded-md text-muted-foreground opacity-0 transition-opacity hover:bg-destructive/10 hover:text-destructive group-hover:opacity-100"
|
||||||
|
onclick={(e) => handleDelete(e, s.id)}
|
||||||
|
title={confirmDelete === s.id ? 'Click again to confirm' : 'Delete task'}
|
||||||
|
aria-label="Delete task"
|
||||||
|
>
|
||||||
|
{#if confirmDelete === s.id}
|
||||||
|
<span class="text-[10px] font-bold text-destructive">Del?</span>
|
||||||
|
{:else}
|
||||||
|
<Trash2Icon class="size-4" />
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user