diff --git a/docs/dev/wiki.md b/docs/dev/wiki.md index cd10450..ff324f7 100644 --- a/docs/dev/wiki.md +++ b/docs/dev/wiki.md @@ -212,7 +212,17 @@ Docs: profile) as the final user turn, runs `runHeadlessToolLoop` against `wikiToolbox`. Side effects (the `wiki_*` tool calls) ARE - the output; final text is discarded. + the persistent output; the model's final text is a one-or- + two-sentence operator-facing summary of its choices ("Updated + Nak article with March 2026 logo details" / "No edits - + generic Q&A with no user-centric subject") that the cycle + driver inlines as `reasoning="..."` on the finished-thread + log line, matching the journal worker's shape. The prompt's + "Final reply" block instructs the model to surface both + decisions made and decisions skipped (e.g. why a topic that + came up was deliberately NOT given its own article), so a + human skimming the log drawer can see WHY a cycle was a no-op + without having to re-read the conversation. - `WikiAgent.updateOne({ articleId, currentTitle, currentContent, userInstructions, signal })` - the main-thread per-article manual entry. Single Venice @@ -437,6 +447,20 @@ JSON). pointer. Without this, every cycle would re-process the same "the model decided this conversation has nothing worth wiki-ing" conversation forever. +- **Final-text is load-bearing now.** Both the per-conversation + wiki agent and the librarian historically ended with "reply + with a single word; the word is discarded" so the tool loop + would terminate cleanly. That changed: the final reply is now + the operator-facing reasoning surfaced as `reasoning="..."` + on the cycle's `finished thread` / `librarian finished` log + line. The prompts ask for one or two plain-text sentences + naming what the agent did or skipped and why; the loop + normalises whitespace and inlines that string. Do not revert + the prompt to "single word" without also dropping the + reasoning surface on the loop side - users debug "why did the + agent decide X" by reading those summaries in the log drawer, + and the librarian's "two articles I considered merging but + left alone" case is only visible there. - **`wiki_create` rephrases unique-violations.** The autonomous agent reads tool-error text as guidance; the raw Postgres `duplicate key value violates unique diff --git a/src/lib/agents/wiki-librarian/agent.ts b/src/lib/agents/wiki-librarian/agent.ts index b77439f..0f14548 100644 --- a/src/lib/agents/wiki-librarian/agent.ts +++ b/src/lib/agents/wiki-librarian/agent.ts @@ -4,7 +4,10 @@ * for each, and runs `runHeadlessToolLoop` against the * `wikiLibrarianToolbox` (wiki_search + wiki_update + wiki_delete + * conversation_search). Side effects from those tool calls ARE the - * output; the model's final text is discarded. + * persistent output; the model's final text is its one-or-two- + * sentence operator summary of merges, deletions, and considered- + * but-left-alone cases (see WikiLibrarianOutput.finalText), surfaced + * in the log drawer by the cycle driver. * * No per-thread claim, no entry_date, no terminal-message slicing - * the librarian operates on the wiki as a whole, on a separate diff --git a/src/lib/agents/wiki-librarian/loop.ts b/src/lib/agents/wiki-librarian/loop.ts index 5e28827..6d14e8b 100644 --- a/src/lib/agents/wiki-librarian/loop.ts +++ b/src/lib/agents/wiki-librarian/loop.ts @@ -149,9 +149,18 @@ export async function runOneCycle(ctx: CycleContext): Promise { return 'error'; } + // Reasoning is the agent's brief operator-facing summary of what + // it merged / deleted / left alone (see the "Final reply" block in + // ../wiki-librarian/prompt.ts). Normalise whitespace so a multi- + // line reply still fits on one log line, and fall back to a + // sentinel when the model returned empty (shouldn't happen in + // production but better surfaced as "(none)" than a dangling + // `reasoning=""`). + const reasoning = + runResult.output.finalText.replace(/\s+/g, ' ').trim() || '(none)'; log.info( `librarian finished (${runResult.toolCalls} tool calls over ` + - `${runResult.output.articleCount} articles)` + `${runResult.output.articleCount} articles, reasoning="${reasoning}")` ); return 'reviewed'; } diff --git a/src/lib/agents/wiki-librarian/prompt.ts b/src/lib/agents/wiki-librarian/prompt.ts index f30bffe..44a7c33 100644 --- a/src/lib/agents/wiki-librarian/prompt.ts +++ b/src/lib/agents/wiki-librarian/prompt.ts @@ -350,8 +350,26 @@ export function buildWikiLibrarianPrompt(opts: { " directly (a first name, the project name) rather than \"the", ' user".', '', - 'When you have nothing more to do, reply with a single word. The', - 'word is discarded - only the tool calls matter. Zero edits is a', - 'normal outcome on a small or already-coherent wiki.', + '**Final reply: one or two sentences explaining your choices.**', + 'After your last tool call (or instead of any tool call, if you', + 'decided the wiki was already coherent), reply with a brief', + 'operator-facing summary of what you did and WHY. This text', + 'surfaces in the user\'s log drawer as the cycle\'s outcome, so make', + 'it useful to a human skimming the log - name the articles you', + 'merged or deleted, and name the cases you considered but left', + 'alone. The "considered but left alone" half is as valuable as the', + '"changed it" half: if two articles looked like duplicates but you', + 'decided they cover different subjects, say so. Examples:', + ' "Deleted \'Kermit protocol\' as out-of-scope; merged the two', + ' \'Maya\' articles into one (the household one absorbed the', + ' sister article)."', + ' "Left \'Maya\' and \'household\' separate - they overlap on the', + ' household-finances paragraph but cover different subjects, and', + ' merging would make either article harder to find."', + ' "No edits - wiki is small and coherent."', + 'Skip filler ("Great work!", "I have finished"); lead with the', + 'decisions. Keep it under two sentences. Plain text, no Markdown.', + 'Zero edits is a normal outcome on a small or already-coherent', + 'wiki - say so plainly.', ].join('\n'); } diff --git a/src/lib/agents/wiki-librarian/types.ts b/src/lib/agents/wiki-librarian/types.ts index 355373b..e742907 100644 --- a/src/lib/agents/wiki-librarian/types.ts +++ b/src/lib/agents/wiki-librarian/types.ts @@ -25,7 +25,16 @@ export interface WikiLibrarianInput { } export interface WikiLibrarianOutput { - /** Final post-tool-loop text. Discarded by production callers. */ + /** + * The model's final (post-tool-loop) text. Used as the operator- + * facing reasoning surfaced in the log drawer - the prompt's "Final + * reply" block instructs the librarian to emit a one-or-two-sentence + * summary of what it merged, deleted, or considered-but-left-alone + * (e.g. "Merged the two Maya articles; left 'Maya' and 'household' + * separate because they cover different subjects."). The loop trims + * and inlines this as `reasoning="..."` on the librarian-finished + * log line, matching the shape the journal and wiki workers use. + */ finalText: string; /** Number of articles in the snapshot. Surface for observability. */ articleCount: number; diff --git a/src/lib/agents/wiki/agent.ts b/src/lib/agents/wiki/agent.ts index 4d98412..4eb9fac 100644 --- a/src/lib/agents/wiki/agent.ts +++ b/src/lib/agents/wiki/agent.ts @@ -5,8 +5,10 @@ * settled thread, appends `WIKI_AUTONOMOUS_PROMPT` as the final * user turn, and runs the headless tool loop with `wikiToolbox`. * The loop's side effects (wiki_search / wiki_create / - * wiki_update / wiki_delete calls) ARE the output; final text is - * discarded after being captured for logs. + * wiki_update / wiki_delete calls) ARE the persistent output; + * the final text is the model's one-or-two-sentence operator + * summary of its choices (see WikiOutput.finalText), surfaced + * in the log drawer by the cycle driver. * * - **Manual**: `updateOne()` runs synchronously on the main thread * when the user clicks "Ask agent to update" on a single article. diff --git a/src/lib/agents/wiki/loop.ts b/src/lib/agents/wiki/loop.ts index 459859b..69156f5 100644 --- a/src/lib/agents/wiki/loop.ts +++ b/src/lib/agents/wiki/loop.ts @@ -126,9 +126,19 @@ export async function runOneCycle(ctx: CycleContext): Promise { return 'error'; } if (marked) { + // Reasoning is the agent's brief operator-facing summary of what + // it did and why (see WIKI_AUTONOMOUS_BODY_LINES' "Final reply" + // block in ../wiki/prompt.ts). Normalise whitespace so a stray + // newline does not break the single-line log convention, and + // fall back to a sentinel when the model returned an empty + // string (shouldn't happen in production, but a missing summary + // is still better surfaced as "(none)" than a dangling `reasoning=""`). + const reasoning = + runResult.output.finalText.replace(/\s+/g, ' ').trim() || '(none)'; log.info( `finished thread ${claim.threadId} ` + - `(${runResult.toolCalls} tool calls over ${runResult.output.inputMessageCount} messages) ${titleTag}` + `(${runResult.toolCalls} tool calls over ${runResult.output.inputMessageCount} messages, ` + + `reasoning="${reasoning}") ${titleTag}` ); return 'processed'; } diff --git a/src/lib/agents/wiki/prompt.ts b/src/lib/agents/wiki/prompt.ts index 3d9603d..25f339a 100644 --- a/src/lib/agents/wiki/prompt.ts +++ b/src/lib/agents/wiki/prompt.ts @@ -483,8 +483,22 @@ const WIKI_AUTONOMOUS_BODY_LINES = [ 'the subject". The bar for creating is "this is a coherent subject', 'the user will want to look up by name later", not "this came up".', '', - 'When you have nothing more to write, reply with a single word. The', - 'word is discarded - only the tool calls matter.', + '**Final reply: one or two sentences explaining your choices.** After', + 'your last tool call (or instead of any tool call, if you decided no', + 'edits were warranted), reply with a brief operator-facing summary of', + 'what you did and WHY. This text surfaces in the user\'s log drawer as', + 'the cycle\'s outcome, so make it useful to a human skimming the log -', + 'name the article(s) you touched, or name the reason you skipped.', + 'Examples of good summaries:', + ' "Updated the Nak article with March 2026 logo-brainstorm details;', + ' added a Markdown link out to the Kermit Wikipedia entry."', + ' "No edits - the conversation was generic technical Q&A about regex', + ' with no user-centric subject."', + ' "No edits - the conversation discussed Kermit at length, but it is', + ' not user-centric and no existing Nak article was available to link', + ' it from."', + 'Skip filler ("Great work!", "I have finished"); lead with the', + 'decision. Keep it under two sentences. Plain text, no Markdown.', ]; /** diff --git a/src/lib/agents/wiki/types.ts b/src/lib/agents/wiki/types.ts index e52cfd4..def294b 100644 --- a/src/lib/agents/wiki/types.ts +++ b/src/lib/agents/wiki/types.ts @@ -20,9 +20,14 @@ export interface WikiInput { export interface WikiOutput { /** - * The model's final (post-tool-loop) text. Always discarded by - * production callers - "reply with a single word" per the prompt - - * but returned here for debug logs and test assertions. + * The model's final (post-tool-loop) text. Used as the operator- + * facing reasoning surfaced in the log drawer - the prompt's "Final + * reply" block instructs the model to emit a one-or-two-sentence + * summary of what it did and why (e.g. "Updated the Nak article + * with March 2026 logo details." / "No edits - generic Q&A with no + * user-centric subject."). The loop trims and inlines this as + * `reasoning="..."` on the finished-thread log line, matching the + * shape the journal worker uses. */ finalText: string; /**