From 7cf2c2f7e6a5ff3ed1dbe347c6df3afb9033a820 Mon Sep 17 00:00:00 2001 From: fey Date: Sun, 15 Mar 2026 15:17:53 +0000 Subject: [PATCH 1/2] feat: add (harness docs) module with hooks-docs for hook discoverability Adds HOOK_METADATA as the single source of truth for all hook contracts, replacing the hand-authored docs/hooks.md hook reference. From it we derive: - hooks-docs: a scheme alist injected into every tein context, discoverable via (import (harness docs)) then (describe hooks-docs) or (module-doc hooks-docs 'pre_message) for a specific hook's payload/return contract - docs/hooks.md hook reference section: regenerated via `just generate-docs` (cargo test -p chibi-core --test generate_docs) HARNESS_PREAMBLE converts from &str to LazyLock to splice the generated hooks-docs alist at startup. EVAL_PRELUDE imports (harness docs) so both hooks-docs and harness-tools-docs are pre-available in all contexts. Tests: completeness (every HookPoint variant has a HOOK_METADATA entry), scheme alist evaluation, (harness docs) module availability in both tiers, and markdown freshness (fails if docs/hooks.md is stale). --- AGENTS.md | 5 +- crates/chibi-core/prompts/chibi.md | 7 +- crates/chibi-core/src/tools/eval.rs | 116 ++ crates/chibi-core/src/tools/hooks.rs | 1381 +++++++++++++++++++- crates/chibi-core/src/tools/mod.rs | 4 +- crates/chibi-core/src/tools/synthesised.rs | 86 +- crates/chibi-core/tests/generate_docs.rs | 51 + docs/hooks.md | 695 +++++----- justfile | 6 + 9 files changed, 1942 insertions(+), 409 deletions(-) create mode 100644 crates/chibi-core/tests/generate_docs.rs diff --git a/AGENTS.md b/AGENTS.md index f9a1a18a2..c195852ec 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -81,5 +81,6 @@ LLM communication is delegated to ratatoskr; `gateway.rs` bridges chibi's types - `(tein json)` exports `json-parse` and `json-stringify` (not `json-read-string`). `(tein safe-regexp)` exports `regexp`, `regexp-search`, `regexp-matches?`, `regexp-replace`, `regexp-replace-all`, `regexp-split`, `regexp-extract`, `regexp-fold`, `regexp-match-submatch`, `regexp-match->list`. Requires `regex` cargo feature on tein dep. - `build_sandboxed_harness_context()` in `synthesised.rs` is the `pub(crate)` bridge for `eval.rs` — wraps `build_tein_context("", Sandboxed)` so eval can add its own prelude without duplicating FFI setup. - `scheme_eval` and `execute_synthesised` return structured output: `"result: \nstdout: \nstderr: "`. The `result` field contains the expression's return value (or `"error: ..."`). stdout/stderr show `"(empty)"` when nothing was captured. `format_eval()` stringifies via `to_string()`; `format_tool()` unwraps scheme strings via `as_string()`. `TeinSession::with_capture` uses flush-then-drain-run-flush to isolate each call's output (flush-output-port, R7RS, works in sandboxed contexts). Test helpers: use `extract_result_field(output)` (splits on `"\nstdout: "`) to isolate the result value. -- `harness-tools-docs` is a docs alist (same convention as `introspect-docs`) available in every synthesised-tool and `scheme_eval` context. Covers `define-tool`, `call-tool`, `register-hook`, `generate-id`, and `current-timestamp`. Use `(describe harness-tools-docs)` to list the full public harness API, or `(module-doc harness-tools-docs 'define-tool)` for a specific entry. `describe` takes an alist directly — NOT a symbol. -- `(module-exports '(harness tools))` errors — `(harness tools)` is runtime-registered and absent from tein's build-time `MODULE_EXPORTS` table. Use `harness-tools-docs` for API discovery instead. +- `(harness docs)` is the canonical import for harness API discovery: `(import (harness docs))` then `(describe hooks-docs)` to list all hook points with payload/return contracts, `(module-doc hooks-docs 'pre_message)` for a specific hook, or `(describe harness-tools-docs)` for the harness tool API (`define-tool`, `call-tool`, `register-hook`, etc.). Both `hooks-docs` and `harness-tools-docs` are also available as top-level bindings (pre-imported in `EVAL_PRELUDE`) but `(harness docs)` is the documented access path. `describe` takes an alist directly — NOT a symbol. +- `hooks-docs` is generated at startup from `HOOK_METADATA` (`hooks.rs`) — the single source of truth for all hook contracts. `docs/hooks.md` hook reference is also generated from it via `just generate-docs`. Adding a `HookPoint` variant without a `HOOK_METADATA` entry fails `test_hook_metadata_completeness`. +- `(module-exports '(harness docs))` (and `'(harness tools)`, `'(harness hooks)`) errors — runtime-registered modules are absent from tein's build-time `MODULE_EXPORTS` table. Use `harness-tools-docs` and `hooks-docs` for API discovery instead. diff --git a/crates/chibi-core/prompts/chibi.md b/crates/chibi-core/prompts/chibi.md index f6133e570..1bed979d9 100644 --- a/crates/chibi-core/prompts/chibi.md +++ b/crates/chibi-core/prompts/chibi.md @@ -48,8 +48,11 @@ - (assoc "key" args) extracts call arguments; keys are strings, not symbols - call-tool invokes other registered tools: (call-tool "name" '(("arg" . "val"))) - tools register automatically on write — no restart needed, live on next turn -- runtime API docs available in every context: (describe harness-tools-docs) - - or: (module-doc harness-tools-docs 'define-tool) for a specific entry +- API and hook discovery via (harness docs) — pre-imported, no explicit import needed: + - (describe hooks-docs) → list all hook points with payload/return contracts + - (module-doc hooks-docs 'pre_message) → contract for a specific hook point + - (describe harness-tools-docs) → list harness API (define-tool, call-tool, register-hook, etc.) + - (module-doc harness-tools-docs 'define-tool) → doc for a specific harness API entry - important: (describe X) takes a docs alist directly — NOT a symbol - helper utilities: (generate-id) → 8-hex-char random id, (current-timestamp) → "YYYYMMDD-HHMMz" UTC - single-tool example: diff --git a/crates/chibi-core/src/tools/eval.rs b/crates/chibi-core/src/tools/eval.rs index 0791c5068..d268bf1df 100644 --- a/crates/chibi-core/src/tools/eval.rs +++ b/crates/chibi-core/src/tools/eval.rs @@ -576,6 +576,122 @@ mod tests { } } + // --- (harness docs) module tests --- + + #[test] + fn test_harness_docs_available_sandboxed() { + // (import (harness docs)) must succeed in sandboxed context + let (session, _) = + crate::tools::synthesised::build_sandboxed_harness_context().expect("build context"); + let result = session + .evaluate("(import (harness docs)) #t") + .expect("import (harness docs)"); + assert_eq!(result, tein::Value::Boolean(true)); + } + + #[test] + fn test_harness_docs_hooks_docs_bound_sandboxed() { + // hooks-docs must be a non-empty pair in sandboxed context + let (session, _) = + crate::tools::synthesised::build_sandboxed_harness_context().expect("build context"); + let result = session + .evaluate("(pair? hooks-docs)") + .expect("evaluate pair?"); + assert_eq!(result, tein::Value::Boolean(true)); + } + + #[test] + fn test_harness_docs_harness_tools_docs_bound() { + // harness-tools-docs must still be accessible after (harness docs) import + let (session, _) = + crate::tools::synthesised::build_sandboxed_harness_context().expect("build context"); + let result = session + .evaluate("(pair? harness-tools-docs)") + .expect("evaluate pair?"); + assert_eq!(result, tein::Value::Boolean(true)); + } + + #[test] + fn test_hooks_docs_module_doc() { + // (module-doc hooks-docs 'pre_message) must return a doc string + let (session, _) = + crate::tools::synthesised::build_sandboxed_harness_context().expect("build context"); + let result = session + .evaluate("(module-doc hooks-docs 'pre_message)") + .expect("evaluate module-doc"); + match result { + tein::Value::String(s) => { + assert!( + !s.is_empty(), + "module-doc hooks-docs pre_message must be non-empty" + ); + assert!( + s.contains("message") || s.contains("prompt"), + "expected pre_message doc to mention message or prompt: {s}" + ); + } + other => panic!("expected string from module-doc, got: {other:?}"), + } + } + + #[test] + fn test_hooks_docs_describe_mentions_hooks() { + // (describe hooks-docs) must mention known hook names + let (session, _) = + crate::tools::synthesised::build_sandboxed_harness_context().expect("build context"); + let result = session + .evaluate("(describe hooks-docs)") + .expect("evaluate describe"); + match result { + tein::Value::String(s) => { + assert!( + s.contains("pre_message") && s.contains("post_tool"), + "describe hooks-docs must mention pre_message and post_tool: {s}" + ); + } + other => panic!("expected string from describe, got: {other:?}"), + } + } + + #[test] + fn test_hooks_docs_markdown_freshness() { + // The generated hook reference section must match what's in docs/hooks.md. + // Run `just generate-docs` to regenerate after changing HOOK_METADATA. + use crate::tools::generate_hooks_markdown; + + let generated = generate_hooks_markdown(); + + // Find docs/hooks.md relative to the manifest directory + let hooks_md_path = std::path::PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("docs/hooks.md"); + + let hooks_md = std::fs::read_to_string(&hooks_md_path) + .unwrap_or_else(|_| panic!("could not read {}", hooks_md_path.display())); + + const BEGIN_MARKER: &str = + ""; + const END_MARKER: &str = ""; + + let begin_pos = hooks_md.find(BEGIN_MARKER).unwrap_or_else(|| { + panic!("BEGIN GENERATED marker not found in docs/hooks.md — run `just generate-docs`") + }); + let end_pos = hooks_md.find(END_MARKER).unwrap_or_else(|| { + panic!("END GENERATED marker not found in docs/hooks.md — run `just generate-docs`") + }); + + let existing = &hooks_md[begin_pos + BEGIN_MARKER.len()..end_pos]; + let expected = format!("\n{generated}\n"); + + assert_eq!( + existing, expected, + "docs/hooks.md generated section is stale — run `just generate-docs` to update" + ); + } + #[test] fn test_evict_eval_context() { // Insert a context, evict it, verify it's gone and a fresh one is created. diff --git a/crates/chibi-core/src/tools/hooks.rs b/crates/chibi-core/src/tools/hooks.rs index a29179b37..85aeeb5a0 100644 --- a/crates/chibi-core/src/tools/hooks.rs +++ b/crates/chibi-core/src/tools/hooks.rs @@ -6,7 +6,7 @@ use super::Tool; use std::io::{self, Write}; use std::process::{Command, Stdio}; -use strum::{AsRefStr, EnumString}; +use strum::{AsRefStr, EnumIter, EnumString}; #[cfg(feature = "synthesised-tools")] use std::cell::RefCell; @@ -25,7 +25,7 @@ thread_local! { } /// Hook points where tools can register to be called -#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, AsRefStr)] +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, EnumString, EnumIter, AsRefStr)] #[strum(serialize_all = "snake_case")] pub enum HookPoint { PreMessage, @@ -63,6 +63,1285 @@ pub enum HookPoint { PostVfsWrite, // After a successful VFS file write (observe only) } +// --- hook metadata for discoverability --- + +/// Describes one field in a hook's payload or return value. +#[cfg(feature = "synthesised-tools")] +pub(crate) struct FieldMeta { + pub name: &'static str, + /// Schematic type string: "string", "number", "bool", "object", "array" + pub typ: &'static str, + pub description: &'static str, +} + +/// Metadata for one hook point — the canonical single source of truth for +/// hook contracts. Used to generate `hooks-docs` (scheme alist) and +/// `docs/hooks.md` (markdown reference section). +#[cfg(feature = "synthesised-tools")] +pub(crate) struct HookMeta { + pub point: HookPoint, + /// Grouping category: "session", "message", "system_prompt", "tool", + /// "api", "agentic", "file_permission", "url_security", "agent", + /// "cache", "message_delivery", "index", "vfs_write", "context" + pub category: &'static str, + /// One-line description of when the hook fires. + pub description: &'static str, + /// Whether the hook callback can return values that modify behaviour. + pub can_modify: bool, + pub payload_fields: &'static [FieldMeta], + /// Empty slice for observe-only hooks. + pub return_fields: &'static [FieldMeta], + /// Extra context or caveats; empty string if none. + pub notes: &'static str, +} + +#[cfg(feature = "synthesised-tools")] +pub(crate) const HOOK_METADATA: &[HookMeta] = &[ + HookMeta { + point: HookPoint::OnStart, + category: "session", + description: "fires when chibi starts, before any processing", + can_modify: false, + payload_fields: &[ + FieldMeta { + name: "chibi_home", + typ: "string", + description: "chibi home directory path", + }, + FieldMeta { + name: "project_root", + typ: "string", + description: "project root directory path", + }, + FieldMeta { + name: "tool_count", + typ: "number", + description: "number of loaded tools", + }, + ], + return_fields: &[], + notes: "", + }, + HookMeta { + point: HookPoint::OnEnd, + category: "session", + description: "fires when chibi exits, after all processing", + can_modify: false, + payload_fields: &[], + return_fields: &[], + notes: "receives empty payload", + }, + HookMeta { + point: HookPoint::PreMessage, + category: "message", + description: "fires before sending a prompt to the LLM", + can_modify: true, + payload_fields: &[ + FieldMeta { + name: "prompt", + typ: "string", + description: "the user's prompt", + }, + FieldMeta { + name: "context_name", + typ: "string", + description: "active context name", + }, + FieldMeta { + name: "summary", + typ: "string", + description: "conversation summary", + }, + ], + return_fields: &[FieldMeta { + name: "prompt", + typ: "string", + description: "modified prompt", + }], + notes: "", + }, + HookMeta { + point: HookPoint::PostMessage, + category: "message", + description: "fires after receiving the LLM response", + can_modify: false, + payload_fields: &[ + FieldMeta { + name: "prompt", + typ: "string", + description: "original prompt", + }, + FieldMeta { + name: "response", + typ: "string", + description: "LLM's response", + }, + FieldMeta { + name: "context_name", + typ: "string", + description: "active context name", + }, + ], + return_fields: &[], + notes: "", + }, + HookMeta { + point: HookPoint::PreSystemPrompt, + category: "system_prompt", + description: "fires before building the system prompt; can inject content", + can_modify: true, + payload_fields: &[ + FieldMeta { + name: "context_name", + typ: "string", + description: "active context name", + }, + FieldMeta { + name: "summary", + typ: "string", + description: "conversation summary", + }, + FieldMeta { + name: "flock_goals", + typ: "array", + description: "array of {flock, goals} objects", + }, + ], + return_fields: &[FieldMeta { + name: "inject", + typ: "string", + description: "content to add to system prompt", + }], + notes: "flock_goals replaced the old goals field; todos field removed (use VFS task files)", + }, + HookMeta { + point: HookPoint::PostSystemPrompt, + category: "system_prompt", + description: "fires after building the system prompt; can inject content", + can_modify: true, + payload_fields: &[ + FieldMeta { + name: "context_name", + typ: "string", + description: "active context name", + }, + FieldMeta { + name: "summary", + typ: "string", + description: "conversation summary", + }, + FieldMeta { + name: "flock_goals", + typ: "array", + description: "array of {flock, goals} objects", + }, + ], + return_fields: &[FieldMeta { + name: "inject", + typ: "string", + description: "content to add to system prompt", + }], + notes: "same payload/return as pre_system_prompt", + }, + HookMeta { + point: HookPoint::PreTool, + category: "tool", + description: "fires before executing a tool; can modify arguments or block", + can_modify: true, + payload_fields: &[ + FieldMeta { + name: "tool_name", + typ: "string", + description: "name of the tool being called", + }, + FieldMeta { + name: "arguments", + typ: "object", + description: "tool arguments object", + }, + ], + return_fields: &[ + FieldMeta { + name: "arguments", + typ: "object", + description: "modified arguments", + }, + FieldMeta { + name: "block", + typ: "bool", + description: "set true to block execution", + }, + FieldMeta { + name: "message", + typ: "string", + description: "message shown when blocked", + }, + ], + notes: "", + }, + HookMeta { + point: HookPoint::PostTool, + category: "tool", + description: "fires after executing a tool; observe only", + can_modify: false, + payload_fields: &[ + FieldMeta { + name: "tool_name", + typ: "string", + description: "name of the tool that ran", + }, + FieldMeta { + name: "arguments", + typ: "object", + description: "tool arguments object", + }, + FieldMeta { + name: "result", + typ: "string", + description: "tool output", + }, + FieldMeta { + name: "cached", + typ: "bool", + description: "true if output was cached due to size", + }, + ], + return_fields: &[], + notes: "", + }, + HookMeta { + point: HookPoint::PreToolOutput, + category: "tool", + description: "fires after tool returns, before caching decisions; can modify or block output", + can_modify: true, + payload_fields: &[ + FieldMeta { + name: "tool_name", + typ: "string", + description: "name of the tool that ran", + }, + FieldMeta { + name: "arguments", + typ: "object", + description: "tool arguments object", + }, + FieldMeta { + name: "output", + typ: "string", + description: "raw tool output", + }, + ], + return_fields: &[ + FieldMeta { + name: "output", + typ: "string", + description: "modified output", + }, + FieldMeta { + name: "block", + typ: "bool", + description: "set true to replace output entirely", + }, + FieldMeta { + name: "message", + typ: "string", + description: "replacement message shown to LLM when blocked", + }, + ], + notes: "", + }, + HookMeta { + point: HookPoint::PostToolOutput, + category: "tool", + description: "fires after tool output processing and caching; observe only", + can_modify: false, + payload_fields: &[ + FieldMeta { + name: "tool_name", + typ: "string", + description: "name of the tool that ran", + }, + FieldMeta { + name: "arguments", + typ: "object", + description: "tool arguments object", + }, + FieldMeta { + name: "output", + typ: "string", + description: "original output after pre_tool_output modifications", + }, + FieldMeta { + name: "final_output", + typ: "string", + description: "what the LLM will see (may be truncated if cached)", + }, + FieldMeta { + name: "cached", + typ: "bool", + description: "true if output was cached", + }, + ], + return_fields: &[], + notes: "", + }, + HookMeta { + point: HookPoint::PreApiTools, + category: "api", + description: "fires before tools are sent to the API; can filter tools", + can_modify: true, + payload_fields: &[ + FieldMeta { + name: "context_name", + typ: "string", + description: "active context name", + }, + FieldMeta { + name: "tools", + typ: "array", + description: "array of {name, type} tool objects", + }, + FieldMeta { + name: "fuel_remaining", + typ: "number", + description: "remaining tool-call budget", + }, + FieldMeta { + name: "fuel_total", + typ: "number", + description: "total fuel budget", + }, + ], + return_fields: &[ + FieldMeta { + name: "exclude", + typ: "array", + description: "tool names to remove (union across hooks)", + }, + FieldMeta { + name: "include", + typ: "array", + description: "allowlist: only these tools remain (intersection across hooks)", + }, + ], + notes: "include/exclude are mutually exclusive per response; excludes union, includes intersect across multiple hooks", + }, + HookMeta { + point: HookPoint::PreApiRequest, + category: "api", + description: "fires after tool filtering, before HTTP request; can modify request body", + can_modify: true, + payload_fields: &[ + FieldMeta { + name: "context_name", + typ: "string", + description: "active context name", + }, + FieldMeta { + name: "request_body", + typ: "object", + description: "full request body (model, messages, tools, etc.)", + }, + FieldMeta { + name: "fuel_remaining", + typ: "number", + description: "remaining tool-call budget", + }, + FieldMeta { + name: "fuel_total", + typ: "number", + description: "total fuel budget", + }, + ], + return_fields: &[FieldMeta { + name: "request_body", + typ: "object", + description: "fields to merge into request body (partial override)", + }], + notes: "returned fields are merged, not replaced; cache_prompt and exclude_from_output are chibi-internal field names", + }, + HookMeta { + point: HookPoint::PreAgenticLoop, + category: "agentic", + description: "fires before each agentic loop iteration; can override fallback and fuel", + can_modify: true, + payload_fields: &[ + FieldMeta { + name: "context_name", + typ: "string", + description: "active context name", + }, + FieldMeta { + name: "fuel_remaining", + typ: "number", + description: "remaining tool-call budget", + }, + FieldMeta { + name: "fuel_total", + typ: "number", + description: "total fuel budget", + }, + FieldMeta { + name: "current_fallback", + typ: "string", + description: "current fallback target (call_agent or call_user)", + }, + FieldMeta { + name: "message", + typ: "string", + description: "user message for this loop", + }, + ], + return_fields: &[ + FieldMeta { + name: "fallback", + typ: "string", + description: "override fallback: call_agent or call_user", + }, + FieldMeta { + name: "fuel", + typ: "number", + description: "set fuel_remaining to this value", + }, + ], + notes: "", + }, + HookMeta { + point: HookPoint::PostToolBatch, + category: "agentic", + description: "fires after processing a batch of tool calls; can override fallback and adjust fuel", + can_modify: true, + payload_fields: &[ + FieldMeta { + name: "context_name", + typ: "string", + description: "active context name", + }, + FieldMeta { + name: "fuel_remaining", + typ: "number", + description: "remaining tool-call budget", + }, + FieldMeta { + name: "fuel_total", + typ: "number", + description: "total fuel budget", + }, + FieldMeta { + name: "current_fallback", + typ: "string", + description: "current fallback target", + }, + FieldMeta { + name: "tool_calls", + typ: "array", + description: "array of {name, arguments} for tools that ran", + }, + ], + return_fields: &[ + FieldMeta { + name: "fallback", + typ: "string", + description: "override fallback: call_agent or call_user", + }, + FieldMeta { + name: "fuel_delta", + typ: "number", + description: "adjust fuel by this amount (positive adds, negative consumes, saturating)", + }, + ], + notes: "post_tool_batch output > pre_agentic_loop output > config fallback; last hook to set fallback wins", + }, + HookMeta { + point: HookPoint::PreFileRead, + category: "file_permission", + description: "fires before reading a file outside allowed paths; deny-only permission protocol", + can_modify: true, + payload_fields: &[ + FieldMeta { + name: "tool_name", + typ: "string", + description: "file_head, file_tail, or file_lines", + }, + FieldMeta { + name: "path", + typ: "string", + description: "absolute path being read", + }, + ], + return_fields: &[ + FieldMeta { + name: "denied", + typ: "bool", + description: "set true to block the read", + }, + FieldMeta { + name: "reason", + typ: "string", + description: "reason shown when denied", + }, + ], + notes: "fail-safe deny if no handler; empty {} response falls through to frontend handler", + }, + HookMeta { + point: HookPoint::PreFileWrite, + category: "file_permission", + description: "fires before write_file or file_edit; deny-only permission protocol", + can_modify: true, + payload_fields: &[ + FieldMeta { + name: "tool_name", + typ: "string", + description: "write_file or file_edit", + }, + FieldMeta { + name: "path", + typ: "string", + description: "absolute path being written", + }, + FieldMeta { + name: "content", + typ: "string", + description: "file content (null for file_edit)", + }, + ], + return_fields: &[ + FieldMeta { + name: "denied", + typ: "bool", + description: "set true to block the write", + }, + FieldMeta { + name: "reason", + typ: "string", + description: "reason shown when denied", + }, + ], + notes: "fail-safe deny if no permission handler configured", + }, + HookMeta { + point: HookPoint::PreShellExec, + category: "file_permission", + description: "fires before shell_exec; deny-only permission protocol", + can_modify: true, + payload_fields: &[ + FieldMeta { + name: "tool_name", + typ: "string", + description: "shell_exec", + }, + FieldMeta { + name: "command", + typ: "string", + description: "shell command string", + }, + ], + return_fields: &[ + FieldMeta { + name: "denied", + typ: "bool", + description: "set true to block execution", + }, + FieldMeta { + name: "reason", + typ: "string", + description: "reason shown when denied", + }, + ], + notes: "same deny-only protocol as pre_file_read and pre_file_write", + }, + HookMeta { + point: HookPoint::PreFetchUrl, + category: "url_security", + description: "fires before fetching a sensitive URL (loopback, private, cloud metadata); deny-only", + can_modify: true, + payload_fields: &[ + FieldMeta { + name: "tool_name", + typ: "string", + description: "fetch_url", + }, + FieldMeta { + name: "url", + typ: "string", + description: "URL being fetched", + }, + FieldMeta { + name: "safety", + typ: "string", + description: "sensitive", + }, + FieldMeta { + name: "reason", + typ: "string", + description: "loopback address, private network address, cloud metadata endpoint, or could not parse URL", + }, + ], + return_fields: &[ + FieldMeta { + name: "denied", + typ: "bool", + description: "set true to block the fetch", + }, + FieldMeta { + name: "reason", + typ: "string", + description: "reason shown when denied", + }, + ], + notes: "only fires when no url_policy is configured; url_policy is authoritative when set", + }, + HookMeta { + point: HookPoint::PreSpawnAgent, + category: "agent", + description: "fires before a sub-agent LLM call; can intercept/replace or block", + can_modify: true, + payload_fields: &[ + FieldMeta { + name: "system_prompt", + typ: "string", + description: "system prompt for sub-agent", + }, + FieldMeta { + name: "input", + typ: "string", + description: "input content to process", + }, + FieldMeta { + name: "model", + typ: "string", + description: "model identifier", + }, + FieldMeta { + name: "temperature", + typ: "number", + description: "sampling temperature", + }, + FieldMeta { + name: "max_tokens", + typ: "number", + description: "max tokens for response", + }, + ], + return_fields: &[ + FieldMeta { + name: "response", + typ: "string", + description: "pre-computed response to use instead of LLM call", + }, + FieldMeta { + name: "block", + typ: "bool", + description: "set true to block the sub-agent call", + }, + FieldMeta { + name: "message", + typ: "string", + description: "message shown when blocked", + }, + ], + notes: "", + }, + HookMeta { + point: HookPoint::PostSpawnAgent, + category: "agent", + description: "fires after sub-agent returns; observe only", + can_modify: false, + payload_fields: &[ + FieldMeta { + name: "system_prompt", + typ: "string", + description: "system prompt used", + }, + FieldMeta { + name: "input", + typ: "string", + description: "input content", + }, + FieldMeta { + name: "model", + typ: "string", + description: "model identifier", + }, + FieldMeta { + name: "response", + typ: "string", + description: "sub-agent's response", + }, + ], + return_fields: &[], + notes: "", + }, + HookMeta { + point: HookPoint::PreCacheOutput, + category: "cache", + description: "fires before caching a large tool output; can provide custom summary", + can_modify: true, + payload_fields: &[ + FieldMeta { + name: "tool_name", + typ: "string", + description: "tool whose output is being cached", + }, + FieldMeta { + name: "arguments", + typ: "object", + description: "tool arguments", + }, + FieldMeta { + name: "content", + typ: "string", + description: "full output content", + }, + FieldMeta { + name: "char_count", + typ: "number", + description: "character count of content", + }, + FieldMeta { + name: "line_count", + typ: "number", + description: "line count of content", + }, + ], + return_fields: &[FieldMeta { + name: "summary", + typ: "string", + description: "custom summary to show LLM instead of full content", + }], + notes: "", + }, + HookMeta { + point: HookPoint::PostCacheOutput, + category: "cache", + description: "fires after output is cached; observe only", + can_modify: false, + payload_fields: &[ + FieldMeta { + name: "tool_name", + typ: "string", + description: "tool whose output was cached", + }, + FieldMeta { + name: "cache_id", + typ: "string", + description: "filename under vfs:///sys/tool_cache//", + }, + FieldMeta { + name: "output_size", + typ: "number", + description: "size of cached output in bytes", + }, + FieldMeta { + name: "preview_size", + typ: "number", + description: "size of preview shown to LLM", + }, + ], + return_fields: &[], + notes: "access cached content with file_head/file_tail/file_lines using full vfs:// URI", + }, + HookMeta { + point: HookPoint::PreSendMessage, + category: "message_delivery", + description: "fires before delivering an inter-context message; can claim delivery", + can_modify: true, + payload_fields: &[ + FieldMeta { + name: "from", + typ: "string", + description: "sending context name", + }, + FieldMeta { + name: "to", + typ: "string", + description: "recipient context name", + }, + FieldMeta { + name: "content", + typ: "string", + description: "message content", + }, + FieldMeta { + name: "context_name", + typ: "string", + description: "active context name", + }, + ], + return_fields: &[ + FieldMeta { + name: "delivered", + typ: "bool", + description: "set true to claim delivery was handled", + }, + FieldMeta { + name: "via", + typ: "string", + description: "delivery mechanism name (for logging)", + }, + ], + notes: "", + }, + HookMeta { + point: HookPoint::PostSendMessage, + category: "message_delivery", + description: "fires after message delivery; observe only", + can_modify: false, + payload_fields: &[ + FieldMeta { + name: "from", + typ: "string", + description: "sending context name", + }, + FieldMeta { + name: "to", + typ: "string", + description: "recipient context name", + }, + FieldMeta { + name: "content", + typ: "string", + description: "message content", + }, + FieldMeta { + name: "context_name", + typ: "string", + description: "active context name", + }, + FieldMeta { + name: "delivery_result", + typ: "string", + description: "delivery outcome description", + }, + ], + return_fields: &[], + notes: "", + }, + HookMeta { + point: HookPoint::PostIndexFile, + category: "index", + description: "fires after a file is indexed by the code indexer; observe only", + can_modify: false, + payload_fields: &[ + FieldMeta { + name: "path", + typ: "string", + description: "relative path of indexed file", + }, + FieldMeta { + name: "lang", + typ: "string", + description: "detected language", + }, + FieldMeta { + name: "symbol_count", + typ: "number", + description: "number of symbols indexed", + }, + FieldMeta { + name: "ref_count", + typ: "number", + description: "number of references indexed", + }, + ], + return_fields: &[], + notes: "", + }, + HookMeta { + point: HookPoint::PreVfsWrite, + category: "vfs_write", + description: "fires before a VFS file write via tool dispatch; advisory, non-blocking", + can_modify: false, + payload_fields: &[ + FieldMeta { + name: "tool_name", + typ: "string", + description: "write_file or file_edit", + }, + FieldMeta { + name: "path", + typ: "string", + description: "VFS path being written", + }, + FieldMeta { + name: "content", + typ: "string", + description: "new content (null for file_edit)", + }, + FieldMeta { + name: "caller", + typ: "string", + description: "context initiating the write", + }, + ], + return_fields: &[], + notes: "only fires for context-initiated writes via send.rs; VfsCaller::System and (harness io) bypass this hook", + }, + HookMeta { + point: HookPoint::PostVfsWrite, + category: "vfs_write", + description: "fires after a successful VFS file write via tool dispatch; observe only", + can_modify: false, + payload_fields: &[ + FieldMeta { + name: "tool_name", + typ: "string", + description: "write_file or file_edit", + }, + FieldMeta { + name: "path", + typ: "string", + description: "VFS path that was written", + }, + FieldMeta { + name: "caller", + typ: "string", + description: "context that initiated the write", + }, + ], + return_fields: &[], + notes: "same caller restriction as pre_vfs_write", + }, + HookMeta { + point: HookPoint::PreClear, + category: "context", + description: "fires before clearing a context; observe only", + can_modify: false, + payload_fields: &[ + FieldMeta { + name: "context_name", + typ: "string", + description: "context being cleared", + }, + FieldMeta { + name: "message_count", + typ: "number", + description: "number of messages before clear", + }, + FieldMeta { + name: "summary", + typ: "string", + description: "existing conversation summary", + }, + ], + return_fields: &[], + notes: "", + }, + HookMeta { + point: HookPoint::PostClear, + category: "context", + description: "fires after clearing a context; observe only", + can_modify: false, + payload_fields: &[ + FieldMeta { + name: "context_name", + typ: "string", + description: "context that was cleared", + }, + FieldMeta { + name: "message_count", + typ: "number", + description: "message count before clear", + }, + FieldMeta { + name: "summary", + typ: "string", + description: "summary before clear", + }, + ], + return_fields: &[], + notes: "", + }, + HookMeta { + point: HookPoint::PreCompact, + category: "context", + description: "fires before full compaction; observe only", + can_modify: false, + payload_fields: &[ + FieldMeta { + name: "context_name", + typ: "string", + description: "context being compacted", + }, + FieldMeta { + name: "message_count", + typ: "number", + description: "number of messages before compact", + }, + FieldMeta { + name: "summary", + typ: "string", + description: "conversation summary", + }, + ], + return_fields: &[], + notes: "", + }, + HookMeta { + point: HookPoint::PostCompact, + category: "context", + description: "fires after full compaction; observe only", + can_modify: false, + payload_fields: &[ + FieldMeta { + name: "context_name", + typ: "string", + description: "context that was compacted", + }, + FieldMeta { + name: "message_count", + typ: "number", + description: "message count before compact", + }, + FieldMeta { + name: "summary", + typ: "string", + description: "conversation summary", + }, + ], + return_fields: &[], + notes: "", + }, + HookMeta { + point: HookPoint::PreRollingCompact, + category: "context", + description: "fires before rolling compaction; observe only", + can_modify: false, + payload_fields: &[ + FieldMeta { + name: "context_name", + typ: "string", + description: "context being compacted", + }, + FieldMeta { + name: "message_count", + typ: "number", + description: "total message count", + }, + FieldMeta { + name: "non_system_count", + typ: "number", + description: "non-system message count", + }, + FieldMeta { + name: "summary", + typ: "string", + description: "conversation summary", + }, + ], + return_fields: &[], + notes: "", + }, + HookMeta { + point: HookPoint::PostRollingCompact, + category: "context", + description: "fires after rolling compaction; observe only", + can_modify: false, + payload_fields: &[ + FieldMeta { + name: "context_name", + typ: "string", + description: "context that was compacted", + }, + FieldMeta { + name: "message_count", + typ: "number", + description: "message count after archiving", + }, + FieldMeta { + name: "messages_archived", + typ: "number", + description: "number of messages archived", + }, + FieldMeta { + name: "summary", + typ: "string", + description: "updated summary", + }, + ], + return_fields: &[], + notes: "", + }, +]; + +#[cfg(feature = "synthesised-tools")] +/// Generate a scheme alist string for `hooks-docs` injected into the tein runtime. +/// +/// Follows the same convention as `introspect-docs`, `harness-tools-docs`, etc. +/// Each key is the snake_case hook point name (as a symbol); the value is a +/// multi-line human-readable string describing category, payload, and returns. +/// +/// Mutation site: if `HookMeta` structure changes, update the format string below. +pub(crate) fn generate_hooks_docs_alist() -> String { + let mut entries = String::new(); + entries.push_str( + r#"'((__module__ . "hook points — lifecycle hooks for plugins and synthesised tools")"#, + ); + entries.push('\n'); + + for meta in HOOK_METADATA { + let key = meta.point.as_ref(); // snake_case name from strum + + let can_modify_str = if meta.can_modify { "yes" } else { "no" }; + + let payload_str: String = if meta.payload_fields.is_empty() { + " payload: (none)".to_string() + } else { + let fields: Vec = meta + .payload_fields + .iter() + .map(|f| format!("{} ({}): {}", f.name, f.typ, f.description)) + .collect(); + format!(" payload: {}", fields.join(", ")) + }; + + let returns_str: String = if meta.return_fields.is_empty() { + " returns: (observe only)".to_string() + } else { + let fields: Vec = meta + .return_fields + .iter() + .map(|f| format!("{} ({}): {}", f.name, f.typ, f.description)) + .collect(); + format!(" returns: {}", fields.join(", ")) + }; + + let notes_str = if meta.notes.is_empty() { + String::new() + } else { + format!("\n note: {}", meta.notes) + }; + + // Escape backslashes and double-quotes for scheme string literals. + let value = format!( + "category: {} | {} | can modify: {}\n{}\n{}{}", + meta.category, meta.description, can_modify_str, payload_str, returns_str, notes_str, + ); + let escaped = value.replace('\\', "\\\\").replace('"', "\\\""); + entries.push_str(&format!(" ({key} . \"{escaped}\")\n")); + } + + entries.push(')'); + entries +} + +#[cfg(feature = "synthesised-tools")] +/// Generate the hook reference section for `docs/hooks.md`. +/// +/// Produces the content that belongs between the `BEGIN GENERATED` and +/// `END GENERATED` markers. Category-grouped tables followed by per-hook +/// payload/return details (JSON blocks). +/// +/// Mutation site: if table format or per-hook detail format changes, update +/// the freshness test in the test suite and re-run `just generate-docs`. +pub fn generate_hooks_markdown() -> String { + use std::collections::BTreeMap; + + // Ordered category display names + let category_order = [ + ("session", "Session Lifecycle"), + ("message", "Message Lifecycle"), + ("system_prompt", "System Prompt Lifecycle"), + ("tool", "Tool Lifecycle"), + ("api", "API Request Lifecycle"), + ("agentic", "Agentic Loop Lifecycle"), + ("file_permission", "File Permission"), + ("url_security", "URL Security"), + ("agent", "Sub-Agent Lifecycle"), + ("cache", "Tool Output Caching"), + ("message_delivery", "Message Delivery"), + ("index", "Index Lifecycle"), + ("vfs_write", "VFS Write Lifecycle"), + ("context", "Context Lifecycle"), + ]; + + // Group hooks by category preserving HOOK_METADATA order within each group + let mut by_category: BTreeMap<&str, Vec<&HookMeta>> = BTreeMap::new(); + for meta in HOOK_METADATA { + by_category.entry(meta.category).or_default().push(meta); + } + + let mut out = String::new(); + out.push_str("## Hook Points\n"); + + // Tables per category + for (cat_key, cat_display) in &category_order { + let Some(hooks) = by_category.get(cat_key) else { + continue; + }; + out.push('\n'); + out.push_str(&format!("### {cat_display}\n\n")); + out.push_str("| Hook | When | Can Modify |\n"); + out.push_str("|------|------|------------|\n"); + for meta in hooks { + let key = meta.point.as_ref(); + let can_modify = if meta.can_modify { "Yes" } else { "No" }; + out.push_str(&format!( + "| `{key}` | {} | {can_modify} |\n", + meta.description + )); + } + } + + // Per-hook detail sections + out.push_str("\n## Hook Data by Type\n"); + for meta in HOOK_METADATA { + let key = meta.point.as_ref(); + out.push('\n'); + out.push_str(&format!("### {key}\n\n")); + + // Payload JSON block + if meta.payload_fields.is_empty() { + out.push_str("Payload: (empty)\n"); + } else { + out.push_str("```json\n{\n"); + for f in meta.payload_fields { + let example = match f.typ { + "string" => format!( + "\"{}\"", + f.description.split_once(' ').map(|x| x.0).unwrap_or(f.name) + ), + "number" => "0".to_string(), + "bool" => "false".to_string(), + "array" => "[]".to_string(), + "object" => "{}".to_string(), + _ => "null".to_string(), + }; + out.push_str(&format!( + " \"{}\": {} // {}\n", + f.name, example, f.description + )); + } + out.push_str("}\n```\n"); + } + + if !meta.return_fields.is_empty() { + out.push_str("\n**Can return:**\n```json\n{\n"); + for f in meta.return_fields { + let example = match f.typ { + "string" => "\"...\"".to_string(), + "number" => "0".to_string(), + "bool" => "true".to_string(), + "array" => "[]".to_string(), + "object" => "{}".to_string(), + _ => "null".to_string(), + }; + out.push_str(&format!( + " \"{}\": {} // {}\n", + f.name, example, f.description + )); + } + out.push_str("}\n```\n"); + } + + if !meta.notes.is_empty() { + out.push_str(&format!("\n> **Note:** {}\n", meta.notes)); + } + } + + out +} + /// Context needed to set up `BRIDGE_CALL_CTX` during tein hook dispatch, /// enabling tein hook callbacks to use `call-tool` and `(harness io)`. /// @@ -351,6 +1630,104 @@ mod tests { } } + // --- hook metadata tests --- + + #[cfg(feature = "synthesised-tools")] + #[test] + fn test_hook_metadata_completeness() { + // Every HookPoint variant must have an entry in HOOK_METADATA. + // Adding a variant without metadata will fail this test. + use strum::IntoEnumIterator; + + let meta_points: std::collections::HashSet = + HOOK_METADATA.iter().map(|m| m.point).collect(); + + let mut missing = Vec::new(); + for variant in HookPoint::iter() { + if !meta_points.contains(&variant) { + missing.push(variant.as_ref().to_string()); + } + } + + assert!( + missing.is_empty(), + "HookPoint variants missing from HOOK_METADATA: {missing:?}" + ); + assert_eq!( + HOOK_METADATA.len(), + HookPoint::iter().count(), + "HOOK_METADATA has duplicate entries or wrong count" + ); + } + + #[cfg(feature = "synthesised-tools")] + #[test] + fn test_generate_hooks_docs_alist_contains_all_hooks() { + use strum::IntoEnumIterator; + + let alist = generate_hooks_docs_alist(); + // Must start with the standard alist form + assert!( + alist.starts_with("'("), + "alist must start with quote+paren: {alist}" + ); + + // Every hook's snake_case name must appear as a key + for variant in HookPoint::iter() { + let key = variant.as_ref(); + assert!( + alist.contains(&format!("({key} . ")), + "hooks-docs alist missing key: {key}" + ); + } + } + + #[cfg(feature = "synthesised-tools")] + #[test] + fn test_hooks_docs_in_scheme_context() { + let (session, _) = + crate::tools::synthesised::build_sandboxed_harness_context().expect("build context"); + + // hooks-docs must be a non-empty pair + let is_pair = session + .evaluate("(pair? hooks-docs)") + .expect("evaluate pair?"); + assert_eq!(is_pair, tein::Value::Boolean(true)); + + // (describe hooks-docs) must return a string mentioning pre_message + let described = session + .evaluate("(describe hooks-docs)") + .expect("evaluate describe"); + match described { + tein::Value::String(s) => { + assert!( + s.contains("pre_message"), + "describe hooks-docs should mention pre_message: {s}" + ); + } + other => panic!("expected string from describe, got: {other:?}"), + } + } + + #[cfg(feature = "synthesised-tools")] + #[test] + fn test_module_doc_hooks_docs_pre_message() { + let (session, _) = + crate::tools::synthesised::build_sandboxed_harness_context().expect("build context"); + let result = session + .evaluate("(module-doc hooks-docs 'pre_message)") + .expect("evaluate"); + match result { + tein::Value::String(s) => { + assert!( + s.contains("message") || s.contains("prompt"), + "expected pre_message doc string, got: {s}" + ); + } + other => panic!("expected string, got: {other:?}"), + } + } + use super::super::ToolMetadata; use super::super::test_helpers::create_test_script; diff --git a/crates/chibi-core/src/tools/mod.rs b/crates/chibi-core/src/tools/mod.rs index 0bd07c17d..d0357956f 100644 --- a/crates/chibi-core/src/tools/mod.rs +++ b/crates/chibi-core/src/tools/mod.rs @@ -121,7 +121,9 @@ pub fn require_str_param(args: &serde_json::Value, name: &str) -> io::Result = std::sync::LazyLock::new(|| { + let hooks_docs_alist = crate::tools::hooks::generate_hooks_docs_alist(); + format!( + r#" (import (scheme base)) ;; accumulates define-tool entries. each entry is a list: @@ -328,8 +358,8 @@ pub(crate) const HARNESS_PREAMBLE: &str = r#" ;; plugins read this to resolve /home//... VFS paths. (define %context-name% "") -;; docs alist for public harness APIs — use (describe harness-tools-docs) or -;; (module-doc harness-tools-docs 'define-tool) to look up usage. +;; docs alist for public harness APIs — use (import (harness docs)) then +;; (describe harness-tools-docs) or (module-doc harness-tools-docs 'define-tool). ;; follows the same convention as introspect-docs, json-docs, etc. ;; note: (describe X) takes an alist directly, NOT a symbol. (define harness-tools-docs @@ -340,6 +370,12 @@ pub(crate) const HARNESS_PREAMBLE: &str = r#" (generate-id . "procedure: (generate-id) -> string — returns an 8-hex-char random identifier (uuid v4 prefix)") (current-timestamp . "procedure: (current-timestamp) -> string — returns current UTC time as \"YYYYMMDD-HHMMz\""))) +;; docs alist for all hook points — use (import (harness docs)) then +;; (describe hooks-docs) to list all hooks, or (module-doc hooks-docs 'pre_message). +;; generated from HOOK_METADATA (hooks.rs) — single source of truth. +(define hooks-docs + {hooks_docs_alist}) + ;; registers a tool: appends to %tool-registry% in definition order (LIFO via cons). ;; rust reads %tool-registry% after evaluation; non-empty → multi-tool mode. (define-syntax define-tool @@ -360,7 +396,10 @@ pub(crate) const HARNESS_PREAMBLE: &str = r#" (set! %hook-registry% (cons (list (symbol->string hook-name) handler) %hook-registry%))) -"#; +"#, + hooks_docs_alist = hooks_docs_alist, + ) +}); // --- thread-local bridge state ----------------------------------------------- @@ -840,11 +879,13 @@ fn build_tein_context( ctx.define_fn_variadic("call-tool", __tein_call_tool_fn)?; ctx.define_fn_variadic("generate-id", __tein_generate_id_fn)?; ctx.define_fn_variadic("current-timestamp", __tein_current_timestamp_fn)?; - ctx.evaluate(HARNESS_PREAMBLE)?; + ctx.evaluate(&HARNESS_PREAMBLE)?; ctx.register_module(HARNESS_TOOLS_MODULE) .map_err(|e| tein::Error::EvalError(format!("harness module: {e}")))?; ctx.register_module(HARNESS_HOOKS_MODULE) .map_err(|e| tein::Error::EvalError(format!("harness hooks module: {e}")))?; + ctx.register_module(HARNESS_DOCS_MODULE) + .map_err(|e| tein::Error::EvalError(format!("harness docs module: {e}")))?; // (harness io) — privileged direct IO, available at Unsandboxed tier only. // Sandboxed contexts will get a module-not-found error on (import (harness io)). if tier == crate::config::SandboxTier::Unsandboxed { @@ -944,7 +985,8 @@ pub(crate) const EVAL_PRELUDE: &str = r#" (srfi 132) (srfi 133) (chibi match) - (harness tools)) + (harness tools) + (harness docs)) ;; R5RS aliases — LLMs reach for these instinctively (define exact->inexact inexact) @@ -1735,6 +1777,28 @@ mod tests { } } + #[test] + fn test_harness_docs_module_available_both_tiers() { + // (import (harness docs)) must succeed in both sandboxed and unsandboxed contexts, + // and both hooks-docs and harness-tools-docs must be bound and non-empty. + for tier in [ + crate::config::SandboxTier::Sandboxed, + crate::config::SandboxTier::Unsandboxed, + ] { + let (session, _) = + build_tein_context(String::new(), tier).expect("session should build"); + + let hooks_docs_ok = session + .evaluate("(and (pair? hooks-docs) (pair? harness-tools-docs))") + .expect("evaluate pair checks"); + assert_eq!( + hooks_docs_ok, + tein::Value::Boolean(true), + "hooks-docs and harness-tools-docs must be pairs in {tier:?} context" + ); + } + } + fn make_registry() -> Arc> { Arc::new(RwLock::new(ToolRegistry::new())) } diff --git a/crates/chibi-core/tests/generate_docs.rs b/crates/chibi-core/tests/generate_docs.rs new file mode 100644 index 000000000..4f208915f --- /dev/null +++ b/crates/chibi-core/tests/generate_docs.rs @@ -0,0 +1,51 @@ +/// Integration test that regenerates the hook reference section in `docs/hooks.md`. +/// +/// Run via: `just generate-docs` +/// Which executes: `cargo test -p chibi-core --test generate_docs -- --nocapture` +/// +/// The test also acts as a freshness check: if the generated content matches +/// what's in the file, the test passes silently. If not, it updates the file +/// and reports what changed (but still passes, since that's the generator's job). +/// +/// The unit test `test_hooks_docs_markdown_freshness` in `eval.rs` is the CI +/// check that fails when the file is stale — it must be run after generate_docs. +use std::path::PathBuf; + +#[test] +fn generate_docs() { + let generated = chibi_core::tools::generate_hooks_markdown(); + + let hooks_md_path = PathBuf::from(env!("CARGO_MANIFEST_DIR")) + .parent() + .unwrap() + .parent() + .unwrap() + .join("docs/hooks.md"); + + let hooks_md = std::fs::read_to_string(&hooks_md_path) + .unwrap_or_else(|_| panic!("could not read {}", hooks_md_path.display())); + + const BEGIN_MARKER: &str = + ""; + const END_MARKER: &str = ""; + + let begin_pos = hooks_md + .find(BEGIN_MARKER) + .expect("BEGIN GENERATED marker not found in docs/hooks.md"); + let end_pos = hooks_md + .find(END_MARKER) + .expect("END GENERATED marker not found in docs/hooks.md"); + + let before = &hooks_md[..begin_pos + BEGIN_MARKER.len()]; + let after = &hooks_md[end_pos..]; + + let new_content = format!("{before}\n{generated}\n{after}"); + + if hooks_md != new_content { + std::fs::write(&hooks_md_path, &new_content) + .unwrap_or_else(|e| panic!("could not write {}: {e}", hooks_md_path.display())); + println!("docs/hooks.md updated."); + } else { + println!("docs/hooks.md is already up-to-date."); + } +} diff --git a/docs/hooks.md b/docs/hooks.md index eebe99284..d6429153b 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -2,178 +2,111 @@ Chibi supports a hooks system that allows plugins to register for lifecycle events. Hooks can observe events or modify data as it flows through the system. + ## Hook Points ### Session Lifecycle | Hook | When | Can Modify | |------|------|------------| -| `on_start` | When chibi starts (before any processing) | No | -| `on_end` | When chibi exits (after all processing) | No | +| `on_start` | fires when chibi starts, before any processing | No | +| `on_end` | fires when chibi exits, after all processing | No | ### Message Lifecycle | Hook | When | Can Modify | |------|------|------------| -| `pre_message` | Before sending a prompt to the LLM | Yes (prompt) | -| `post_message` | After receiving LLM response | No | +| `pre_message` | fires before sending a prompt to the LLM | Yes | +| `post_message` | fires after receiving the LLM response | No | ### System Prompt Lifecycle | Hook | When | Can Modify | |------|------|------------| -| `pre_system_prompt` | Before building system prompt | Yes (inject content) | -| `post_system_prompt` | After building system prompt | Yes (inject content) | +| `pre_system_prompt` | fires before building the system prompt; can inject content | Yes | +| `post_system_prompt` | fires after building the system prompt; can inject content | Yes | ### Tool Lifecycle | Hook | When | Can Modify | |------|------|------------| -| `pre_tool` | Before executing a tool | Yes (arguments, can block) | -| `post_tool` | After executing a tool | No | -| `pre_tool_output` | After tool execution, before caching | Yes (output, can block/replace) | -| `post_tool_output` | After tool output is processed | No | +| `pre_tool` | fires before executing a tool; can modify arguments or block | Yes | +| `post_tool` | fires after executing a tool; observe only | No | +| `pre_tool_output` | fires after tool returns, before caching decisions; can modify or block output | Yes | +| `post_tool_output` | fires after tool output processing and caching; observe only | No | ### API Request Lifecycle | Hook | When | Can Modify | |------|------|------------| -| `pre_api_tools` | Before tools are sent to API | Yes (filter tools) | -| `pre_api_request` | Before API request is sent | Yes (modify request body) | +| `pre_api_tools` | fires before tools are sent to the API; can filter tools | Yes | +| `pre_api_request` | fires after tool filtering, before HTTP request; can modify request body | Yes | ### Agentic Loop Lifecycle | Hook | When | Can Modify | |------|------|------------| -| `pre_agentic_loop` | Before entering the tool loop (each iteration) | Yes (fallback target, fuel) | -| `post_tool_batch` | After processing a batch of tool calls | Yes (fallback target, fuel delta) | +| `pre_agentic_loop` | fires before each agentic loop iteration; can override fallback and fuel | Yes | +| `post_tool_batch` | fires after processing a batch of tool calls; can override fallback and adjust fuel | Yes | -### File Read Permission +### File Permission | Hook | When | Can Modify | |------|------|------------| -| `pre_file_read` | Before reading a file outside `file_tools_allowed_paths` | Yes (approve/deny, fail-safe deny if no handler) | - -### File Write Permission - -| Hook | When | Can Modify | -|------|------|------------| -| `pre_file_write` | Before `write_file`/`file_edit` execution | Yes (approve/deny, fail-safe deny if no hook registered) | +| `pre_file_read` | fires before reading a file outside allowed paths; deny-only permission protocol | Yes | +| `pre_file_write` | fires before write_file or file_edit; deny-only permission protocol | Yes | +| `pre_shell_exec` | fires before shell_exec; deny-only permission protocol | Yes | ### URL Security | Hook | When | Can Modify | |------|------|------------| -| `pre_fetch_url` | Before fetching a sensitive URL (loopback, private network, cloud metadata) | Yes (approve/deny, fail-safe deny if no handler) | +| `pre_fetch_url` | fires before fetching a sensitive URL (loopback, private, cloud metadata); deny-only | Yes | ### Sub-Agent Lifecycle | Hook | When | Can Modify | |------|------|------------| -| `pre_spawn_agent` | Before a sub-agent LLM call | Yes (can provide response or block) | -| `post_spawn_agent` | After sub-agent returns | No | +| `pre_spawn_agent` | fires before a sub-agent LLM call; can intercept/replace or block | Yes | +| `post_spawn_agent` | fires after sub-agent returns; observe only | No | ### Tool Output Caching | Hook | When | Can Modify | |------|------|------------| -| `pre_cache_output` | Before caching large tool output | Yes (can provide custom summary) | -| `post_cache_output` | After output is cached | No | +| `pre_cache_output` | fires before caching a large tool output; can provide custom summary | Yes | +| `post_cache_output` | fires after output is cached; observe only | No | ### Message Delivery | Hook | When | Can Modify | |------|------|------------| -| `pre_send_message` | Before delivering inter-context message | Yes (can claim delivery) | -| `post_send_message` | After message delivery | No | +| `pre_send_message` | fires before delivering an inter-context message; can claim delivery | Yes | +| `post_send_message` | fires after message delivery; observe only | No | ### Index Lifecycle | Hook | When | Can Modify | |------|------|------------| -| `post_index_file` | After a file is indexed by the code indexer | No | +| `post_index_file` | fires after a file is indexed by the code indexer; observe only | No | ### VFS Write Lifecycle | Hook | When | Can Modify | |------|------|------------| -| `pre_vfs_write` | Before a VFS file write (advisory, non-blocking) | No | -| `post_vfs_write` | After a successful VFS file write | No | +| `pre_vfs_write` | fires before a VFS file write via tool dispatch; advisory, non-blocking | No | +| `post_vfs_write` | fires after a successful VFS file write via tool dispatch; observe only | No | ### Context Lifecycle | Hook | When | Can Modify | |------|------|------------| -| `pre_clear` | Before clearing context | No | -| `post_clear` | After clearing context | No | -| `pre_compact` | Before full compaction | No | -| `post_compact` | After full compaction | No | -| `pre_rolling_compact` | Before rolling compaction | No | -| `post_rolling_compact` | After rolling compaction | No | - -## Registering for Hooks - -Plugins register for hooks via their `--schema` JSON output: - -```json -{ - "name": "my_tool", - "description": "Tool description", - "parameters": { - "type": "object", - "properties": {} - }, - "hooks": ["on_start", "pre_message", "post_message"] -} -``` - -## Tein Hook Registration - -Synthesised tools (`.scm` files) can register for hooks using the `(harness hooks)` module: - -```scheme -(import (harness hooks)) - -(register-hook 'pre_message - (lambda (payload) - ;; payload is an alist parsed from the hook's JSON data. - ;; return an alist to modify behaviour, or '() for no-op. - (list (cons "prompt" "modified prompt")))) - -(define tool-name "my-tool") -(define tool-description "A tool that also hooks into pre_message") -(define tool-parameters '()) -(define (tool-execute args) "ok") -``` - -Tein hooks follow the same contract as subprocess plugin hooks: -- They receive the hook payload converted from JSON to a scheme alist. -- They return a scheme alist (converted back to JSON), or `'()` (empty list) for no-op. -- Errors in callbacks are caught and skipped silently (same as subprocess hook failures). -- `register-hook` takes a symbol for the hook point name and a one-argument procedure. - -**Ordering:** subprocess plugin hooks fire first, then tein hooks, in registration order. - -**Re-entrancy:** If a tein hook callback triggers an action that fires the same hook point, -tein callbacks are skipped on the recursive call to prevent infinite loops. Subprocess -hooks still fire normally. - -**IO in hook callbacks:** Tein hook callbacks can use `(harness io)` (unsandboxed tier only) -for direct VFS and filesystem IO without triggering hooks. This is the recommended way for -builtin plugins to perform IO during hook execution. - -Using `call-tool` from hooks is also possible (when the hook is dispatched from a full async -context) but may trigger hooks on the called tool — use with care to avoid re-entrancy. - -**Lifecycle:** Hook registrations are tied to the `.scm` file. When a file is hot-reloaded -or deleted, its hooks are automatically cleared and re-evaluated from the fresh source. - -## Hook Execution - -When a hook fires, registered plugins are called with: - -- `CHIBI_HOOK` env var - Hook point name (e.g., "pre_message") -- stdin - JSON data about the event +| `pre_clear` | fires before clearing a context; observe only | No | +| `post_clear` | fires after clearing a context; observe only | No | +| `pre_compact` | fires before full compaction; observe only | No | +| `post_compact` | fires after full compaction; observe only | No | +| `pre_rolling_compact` | fires before rolling compaction; observe only | No | +| `post_rolling_compact` | fires after rolling compaction; observe only | No | ## Hook Data by Type @@ -181,34 +114,32 @@ When a hook fires, registered plugins are called with: ```json { - "chibi_home": "/home/user/.chibi", - "project_root": "/home/user/project", - "tool_count": 15 + "chibi_home": "chibi" // chibi home directory path + "project_root": "project" // project root directory path + "tool_count": 0 // number of loaded tools } ``` ### on_end -```json -{} -``` +Payload: (empty) -`on_end` receives an empty payload. +> **Note:** receives empty payload ### pre_message ```json { - "prompt": "user's prompt", - "context_name": "default", - "summary": "conversation summary..." + "prompt": "the" // the user's prompt + "context_name": "active" // active context name + "summary": "conversation" // conversation summary } ``` **Can return:** ```json { - "prompt": "modified prompt" + "prompt": "..." // modified prompt } ``` @@ -216,548 +147,530 @@ When a hook fires, registered plugins are called with: ```json { - "prompt": "original prompt", - "response": "LLM's response", - "context_name": "default" + "prompt": "original" // original prompt + "response": "LLM's" // LLM's response + "context_name": "active" // active context name } ``` -### pre_system_prompt / post_system_prompt +### pre_system_prompt ```json { - "context_name": "default", - "summary": "conversation summary...", - "flock_goals": [ - {"flock": "site:", "goals": "site-wide goals..."}, - {"flock": "myteam", "goals": "team goals..."} - ] + "context_name": "active" // active context name + "summary": "conversation" // conversation summary + "flock_goals": [] // array of {flock, goals} objects } ``` -> **Breaking changes:** the `goals` field was replaced by `flock_goals` (array) in the flocks migration. each entry is `{"flock": "", "goals": ""}`. the array is empty if no flocks have goals set. the `todos` field was removed in the structured-tasks migration (#186); tasks are now VFS-backed `.task` files injected ephemerally into the message stream rather than exposed via hook payloads. - **Can return:** ```json { - "inject": "content to add to system prompt" + "inject": "..." // content to add to system prompt } ``` -### pre_api_tools +> **Note:** flock_goals replaced the old goals field; todos field removed (use VFS task files) -Called before tools are sent to the API. Allows dynamic filtering of which tools the LLM can use. +### post_system_prompt ```json { - "context_name": "default", - "tools": [ - {"name": "update_reflection", "type": "builtin"}, - {"name": "file_head", "type": "file"}, - {"name": "my_plugin", "type": "plugin"} - ], - "fuel_remaining": 30, - "fuel_total": 30 + "context_name": "active" // active context name + "summary": "conversation" // conversation summary + "flock_goals": [] // array of {flock, goals} objects } ``` -Tool types are: -- `builtin`: update_reflection, update_goals, read_context, flock_join, flock_leave, flock_list -- `file`: file_head, file_tail, file_lines, file_grep, write_file -- `agent`: spawn_agent, retrieve_content -- `plugin`: Tools loaded from the plugins directory - -**Can return (to filter tools):** -```json -{ - "exclude": ["file_grep", "my_plugin"] -} -``` - -Or to use allowlist mode: +**Can return:** ```json { - "include": ["update_reflection", "update_goals"] + "inject": "..." // content to add to system prompt } ``` -When multiple hooks respond: -- Includes are **intersected** (most restrictive wins) -- Excludes are **unioned** (all excluded tools are removed) - -### pre_api_request +> **Note:** same payload/return as pre_system_prompt -Called after tools are filtered but before the HTTP request is sent. Allows modification of any part of the request body. +### pre_tool ```json { - "context_name": "default", - "request_body": { - "model": "anthropic/claude-sonnet-4", - "messages": [...], - "tools": [...], - "stream": true, - "temperature": 0.7, - "cache_prompt": true, - ... - }, - "fuel_remaining": 30, - "fuel_total": 30 + "tool_name": "name" // name of the tool being called + "arguments": {} // tool arguments object } ``` -> **Note on field names:** The request body reflects chibi's internal representation. Some fields differ from raw OpenAI/OpenRouter API names: prompt caching appears as `cache_prompt`; reasoning exclude appears as `exclude_from_output`. These are the field names to use when overriding via this hook. - -**Can return (to modify request):** +**Can return:** ```json { - "request_body": { - "temperature": 0.3, - "max_tokens": 2000 - } + "arguments": {} // modified arguments + "block": true // set true to block execution + "message": "..." // message shown when blocked } ``` -Returned fields are **merged** into the request body, not replaced entirely. This allows targeted modifications without needing to echo back the entire body. Modifications are applied to the actual API call — not just to logging. - -### pre_agentic_loop - -Called before each iteration of the agentic loop (including re-entries on agent continuation). Allows plugins to override the fallback handoff target and adjust the fuel budget. +### post_tool ```json { - "context_name": "default", - "fuel_remaining": 30, - "fuel_total": 30, - "current_fallback": "call_agent", - "message": "user's message here" + "tool_name": "name" // name of the tool that ran + "arguments": {} // tool arguments object + "result": "tool" // tool output + "cached": false // true if output was cached due to size } ``` -**Can return (to override fallback):** +### pre_tool_output + ```json { - "fallback": "call_user" + "tool_name": "name" // name of the tool that ran + "arguments": {} // tool arguments object + "output": "raw" // raw tool output } ``` -Valid fallback values are `"call_agent"` (continue processing) or `"call_user"` (return to user). - -**Can return (to set fuel):** +**Can return:** ```json { - "fuel": 50 + "output": "..." // modified output + "block": true // set true to replace output entirely + "message": "..." // replacement message shown to LLM when blocked } ``` -Sets `fuel_remaining` to the given value. Plugins can inspect the current `fuel_remaining` in the hook data and decide whether to top up, cap, or reset the budget. - -### post_tool_batch - -Called after processing a batch of tool calls, before deciding whether to continue or return. Allows plugins to override the fallback and adjust fuel based on which tools were called. +### post_tool_output ```json { - "context_name": "default", - "fuel_remaining": 28, - "fuel_total": 30, - "current_fallback": "call_agent", - "tool_calls": [ - {"name": "file_head", "arguments": {"path": "Cargo.toml"}}, - {"name": "update_goals", "arguments": {"content": "..."}} - ] + "tool_name": "name" // name of the tool that ran + "arguments": {} // tool arguments object + "output": "original" // original output after pre_tool_output modifications + "final_output": "what" // what the LLM will see (may be truncated if cached) + "cached": false // true if output was cached } ``` -**Can return (to override fallback):** +### pre_api_tools + ```json { - "fallback": "call_user" + "context_name": "active" // active context name + "tools": [] // array of {name, type} tool objects + "fuel_remaining": 0 // remaining tool-call budget + "fuel_total": 0 // total fuel budget } ``` -**Can return (to adjust fuel):** +**Can return:** ```json { - "fuel_delta": -5 + "exclude": [] // tool names to remove (union across hooks) + "include": [] // allowlist: only these tools remain (intersection across hooks) } ``` -Positive values add fuel (saturating), negative values consume fuel (saturating to 0). Use this to make certain tool patterns cost more or less fuel. - -**Priority:** `post_tool_batch` output > `pre_agentic_loop` output > config fallback. Each `post_tool_batch` hook can keep overriding, so the last hook to set a fallback wins. +> **Note:** include/exclude are mutually exclusive per response; excludes union, includes intersect across multiple hooks -### pre_tool +### pre_api_request ```json { - "tool_name": "file_head", - "arguments": {"path": "/etc/passwd"} + "context_name": "active" // active context name + "request_body": {} // full request body (model, messages, tools, etc.) + "fuel_remaining": 0 // remaining tool-call budget + "fuel_total": 0 // total fuel budget } ``` **Can return:** ```json { - "arguments": {"path": "/safe/path"} + "request_body": {} // fields to merge into request body (partial override) } ``` -Or to block execution: +> **Note:** returned fields are merged, not replaced; cache_prompt and exclude_from_output are chibi-internal field names + +### pre_agentic_loop + ```json { - "block": true, - "message": "This operation is not allowed" + "context_name": "active" // active context name + "fuel_remaining": 0 // remaining tool-call budget + "fuel_total": 0 // total fuel budget + "current_fallback": "current" // current fallback target (call_agent or call_user) + "message": "user" // user message for this loop } ``` -### post_tool - +**Can return:** ```json { - "tool_name": "file_head", - "arguments": {"path": "Cargo.toml"}, - "result": "file contents...", - "cached": false + "fallback": "..." // override fallback: call_agent or call_user + "fuel": 0 // set fuel_remaining to this value } ``` -Note: `cached` is `true` if the output was cached due to size. - -### pre_tool_output - -Called immediately after a tool returns its output, before any caching decisions. Can modify or replace the output entirely. +### post_tool_batch ```json { - "tool_name": "file_head", - "arguments": {"path": "Cargo.toml"}, - "output": "raw tool output..." + "context_name": "active" // active context name + "fuel_remaining": 0 // remaining tool-call budget + "fuel_total": 0 // total fuel budget + "current_fallback": "current" // current fallback target + "tool_calls": [] // array of {name, arguments} for tools that ran } ``` -**Can return (to modify output):** +**Can return:** ```json { - "output": "modified output..." + "fallback": "..." // override fallback: call_agent or call_user + "fuel_delta": 0 // adjust fuel by this amount (positive adds, negative consumes, saturating) } ``` -Or to block/replace entirely: +> **Note:** post_tool_batch output > pre_agentic_loop output > config fallback; last hook to set fallback wins + +### pre_file_read + ```json { - "block": true, - "message": "Replacement message shown to LLM" + "tool_name": "file_head," // file_head, file_tail, or file_lines + "path": "absolute" // absolute path being read } ``` -### post_tool_output - -Called after tool output processing (including any pre_tool_output modifications and caching decisions). Observe only. - +**Can return:** ```json { - "tool_name": "file_head", - "arguments": {"path": "Cargo.toml"}, - "output": "original output (after pre_tool_output modifications)", - "final_output": "what the LLM will see (may be truncated if cached)", - "cached": false + "denied": true // set true to block the read + "reason": "..." // reason shown when denied } ``` -### pre_cache_output +> **Note:** fail-safe deny if no handler; empty {} response falls through to frontend handler -Called before caching a large tool output. Can provide a custom summary. +### pre_file_write ```json { - "tool_name": "fetch_url", - "arguments": {"url": "https://example.com"}, - "content": "full output content...", - "char_count": 50000, - "line_count": 1200 + "tool_name": "write_file" // write_file or file_edit + "path": "absolute" // absolute path being written + "content": "file" // file content (null for file_edit) } ``` -**Can return (to provide custom summary):** +**Can return:** ```json { - "summary": "Custom summary of the content..." + "denied": true // set true to block the write + "reason": "..." // reason shown when denied } ``` -### post_cache_output +> **Note:** fail-safe deny if no permission handler configured -Notification after output has been cached to VFS. +### pre_shell_exec ```json { - "tool_name": "fetch_url", - "cache_id": "fetch_url_abc123_def456", - "output_size": 50000, - "preview_size": 800 + "tool_name": "tool_name" // shell_exec + "command": "shell" // shell command string } ``` -`cache_id` is the filename under `vfs:///sys/tool_cache//`. access cached content with `file_head`, `file_tail`, `file_lines`, `file_grep` using the full URI. - -### pre_send_message - +**Can return:** ```json { - "from": "default", - "to": "research", - "content": "message content", - "context_name": "default" + "denied": true // set true to block execution + "reason": "..." // reason shown when denied } ``` -**Can return (to claim delivery):** +> **Note:** same deny-only protocol as pre_file_read and pre_file_write + +### pre_fetch_url + ```json { - "delivered": true, - "via": "external-service" + "tool_name": "tool_name" // fetch_url + "url": "URL" // URL being fetched + "safety": "safety" // sensitive + "reason": "loopback" // loopback address, private network address, cloud metadata endpoint, or could not parse URL } ``` -### post_send_message - +**Can return:** ```json { - "from": "default", - "to": "research", - "content": "message content", - "context_name": "default", - "delivery_result": "Message delivered to 'research' via local inbox" + "denied": true // set true to block the fetch + "reason": "..." // reason shown when denied } ``` -### pre_file_read +> **Note:** only fires when no url_policy is configured; url_policy is authoritative when set -Called before `file_head`, `file_tail`, or `file_lines` reads a file **outside** `file_tools_allowed_paths`. Reads inside allowed paths (defaults to cwd) proceed without prompting. Uses the **deny-only** permission protocol: plugins can block, otherwise falls through to the frontend's permission handler. Fail-safe denied if no handler is configured. +### pre_spawn_agent ```json { - "tool_name": "file_head", - "path": "/etc/passwd" + "system_prompt": "system" // system prompt for sub-agent + "input": "input" // input content to process + "model": "model" // model identifier + "temperature": 0 // sampling temperature + "max_tokens": 0 // max tokens for response } ``` -**To deny:** +**Can return:** ```json { - "denied": true, - "reason": "Path not allowed" + "response": "..." // pre-computed response to use instead of LLM call + "block": true // set true to block the sub-agent call + "message": "..." // message shown when blocked } ``` -**No opinion (falls through to frontend handler):** +### post_spawn_agent + ```json -{} +{ + "system_prompt": "system" // system prompt used + "input": "input" // input content + "model": "model" // model identifier + "response": "sub-agent's" // sub-agent's response +} ``` -### pre_file_write - -Called before `write_file` or `file_edit` execution. Uses the **deny-only** permission protocol: plugins act as security gates that can block specific operations. If no plugin denies the operation, it falls through to the frontend's permission handler (e.g. interactive TTY prompt). If no permission handler is configured, operations are fail-safe denied. +### pre_cache_output ```json { - "tool_name": "write_file", - "path": "/home/user/project/file.txt", - "content": "file content here" + "tool_name": "tool" // tool whose output is being cached + "arguments": {} // tool arguments + "content": "full" // full output content + "char_count": 0 // character count of content + "line_count": 0 // line count of content } ``` -**To deny:** +**Can return:** ```json { - "denied": true, - "reason": "Path not allowed" + "summary": "..." // custom summary to show LLM instead of full content } ``` -**No opinion (falls through to frontend handler):** +### post_cache_output + ```json -{} +{ + "tool_name": "tool" // tool whose output was cached + "cache_id": "filename" // filename under vfs:///sys/tool_cache// + "output_size": 0 // size of cached output in bytes + "preview_size": 0 // size of preview shown to LLM +} ``` -### pre_shell_exec +> **Note:** access cached content with file_head/file_tail/file_lines using full vfs:// URI -Called before `shell_exec` execution. Uses the same **deny-only** permission protocol as `pre_file_write`. +### pre_send_message ```json { - "tool_name": "shell_exec", - "command": "ls -la" + "from": "sending" // sending context name + "to": "recipient" // recipient context name + "content": "message" // message content + "context_name": "active" // active context name } ``` -**To deny:** +**Can return:** ```json { - "denied": true, - "reason": "Command not allowed" + "delivered": true // set true to claim delivery was handled + "via": "..." // delivery mechanism name (for logging) } ``` -**No opinion (falls through to frontend handler):** -```json -{} -``` - -### pre_fetch_url - -Called before fetching a URL classified as sensitive (loopback, private network, cloud metadata endpoint, or unparseable). Uses the same **deny-only** permission protocol as `pre_file_read` and `pre_file_write`. Only fires when no `url_policy` is configured — if a policy is set, it is authoritative and this hook is not called. +### post_send_message ```json { - "tool_name": "fetch_url", - "url": "http://localhost:8080/api", - "safety": "sensitive", - "reason": "loopback address" + "from": "sending" // sending context name + "to": "recipient" // recipient context name + "content": "message" // message content + "context_name": "active" // active context name + "delivery_result": "delivery" // delivery outcome description } ``` -Possible `reason` values: `"loopback address"`, `"private network address"`, `"link-local address"`, `"cloud metadata endpoint"`, `"could not parse URL"`. +### post_index_file -**To deny:** ```json { - "denied": true, - "reason": "Local URLs not permitted" + "path": "relative" // relative path of indexed file + "lang": "detected" // detected language + "symbol_count": 0 // number of symbols indexed + "ref_count": 0 // number of references indexed } ``` -**No opinion (falls through to frontend handler):** -```json -{} -``` - -### pre_spawn_agent - -Called before a sub-agent LLM call (from `spawn_agent` or `retrieve_content` tools). Can intercept and replace the call entirely, or block it. +### pre_vfs_write ```json { - "system_prompt": "You are a summarizer...", - "input": "Content to process...", - "model": "anthropic/claude-sonnet-4", - "temperature": 0.7, - "max_tokens": 4096 + "tool_name": "write_file" // write_file or file_edit + "path": "VFS" // VFS path being written + "content": "new" // new content (null for file_edit) + "caller": "context" // context initiating the write } ``` -**Can return (to replace the LLM call):** +> **Note:** only fires for context-initiated writes via send.rs; VfsCaller::System and (harness io) bypass this hook + +### post_vfs_write + ```json { - "response": "Pre-computed or cached response" + "tool_name": "write_file" // write_file or file_edit + "path": "VFS" // VFS path that was written + "caller": "context" // context that initiated the write } ``` -Or to block: +> **Note:** same caller restriction as pre_vfs_write + +### pre_clear + ```json { - "block": true, - "message": "Sub-agent calls are not allowed in this context" + "context_name": "context" // context being cleared + "message_count": 0 // number of messages before clear + "summary": "existing" // existing conversation summary } ``` -### post_spawn_agent +### post_clear ```json { - "system_prompt": "You are a summarizer...", - "input": "Content to process...", - "model": "anthropic/claude-sonnet-4", - "response": "The sub-agent's response..." + "context_name": "context" // context that was cleared + "message_count": 0 // message count before clear + "summary": "summary" // summary before clear } ``` -### pre_clear / post_clear +### pre_compact ```json { - "context_name": "default", - "message_count": 10, - "summary": "existing summary..." + "context_name": "context" // context being compacted + "message_count": 0 // number of messages before compact + "summary": "conversation" // conversation summary } ``` -### pre_compact / post_compact +### post_compact ```json { - "context_name": "default", - "message_count": 20, - "summary": "conversation summary..." + "context_name": "context" // context that was compacted + "message_count": 0 // message count before compact + "summary": "conversation" // conversation summary } ``` -### pre_rolling_compact / post_rolling_compact +### pre_rolling_compact ```json { - "context_name": "default", - "message_count": 50, - "non_system_count": 48, - "summary": "conversation summary..." + "context_name": "context" // context being compacted + "message_count": 0 // total message count + "non_system_count": 0 // non-system message count + "summary": "conversation" // conversation summary } ``` -For `post_rolling_compact`: +### post_rolling_compact + ```json { - "context_name": "default", - "message_count": 25, - "messages_archived": 25, - "summary": "updated summary..." + "context_name": "context" // context that was compacted + "message_count": 0 // message count after archiving + "messages_archived": 0 // number of messages archived + "summary": "updated" // updated summary } ``` -### post_index_file + + +## Registering for Hooks -Fired after a file is successfully indexed by the code indexer. Observe only. +Plugins register for hooks via their `--schema` JSON output: ```json { - "path": "src/main.rs", - "lang": "rust", - "symbol_count": 42, - "ref_count": 17 + "name": "my_tool", + "description": "Tool description", + "parameters": { + "type": "object", + "properties": {} + }, + "hooks": ["on_start", "pre_message", "post_message"] } ``` -### pre_vfs_write +## Tein Hook Registration -Fired before a VFS file write (via `write_file` or `file_edit`). Advisory and non-blocking — cannot prevent the write. Intended for observe-and-snapshot use cases (e.g. the file history plugin). +Synthesised tools (`.scm` files) can register for hooks using the `(harness hooks)` module: -> **Note:** Only fires for writes initiated via tool dispatch (i.e. context-initiated writes). Writes from `VfsCaller::System` and `(harness io)` bypass `send.rs` entirely and do not trigger this hook. +```scheme +(import (harness hooks)) -```json -{ - "tool_name": "write_file", - "path": "vfs:///shared/tool.scm", - "content": "new content here", - "caller": "my-context" -} +(register-hook 'pre_message + (lambda (payload) + ;; payload is an alist parsed from the hook's JSON data. + ;; return an alist to modify behaviour, or '() for no-op. + (list (cons "prompt" "modified prompt")))) + +(define tool-name "my-tool") +(define tool-description "A tool that also hooks into pre_message") +(define tool-parameters '()) +(define (tool-execute args) "ok") ``` -The `content` field is `null` for `file_edit` calls (which use `operation`/`old`/`new` params rather than a full content replacement). +Tein hooks follow the same contract as subprocess plugin hooks: +- They receive the hook payload converted from JSON to a scheme alist. +- They return a scheme alist (converted back to JSON), or `'()` (empty list) for no-op. +- Errors in callbacks are caught and skipped silently (same as subprocess hook failures). +- `register-hook` takes a symbol for the hook point name and a one-argument procedure. -### post_vfs_write +**Ordering:** subprocess plugin hooks fire first, then tein hooks, in registration order. -Fired after a VFS file write completes successfully. Observe only. +**Re-entrancy:** If a tein hook callback triggers an action that fires the same hook point, +tein callbacks are skipped on the recursive call to prevent infinite loops. Subprocess +hooks still fire normally. -> **Note:** Same caller restriction as `pre_vfs_write` — only fires for context-initiated writes via tool dispatch. +**IO in hook callbacks:** Tein hook callbacks can use `(harness io)` (unsandboxed tier only) +for direct VFS and filesystem IO without triggering hooks. This is the recommended way for +builtin plugins to perform IO during hook execution. -```json -{ - "tool_name": "write_file", - "path": "vfs:///shared/tool.scm", - "caller": "my-context" -} -``` +Using `call-tool` from hooks is also possible (when the hook is dispatched from a full async +context) but may trigger hooks on the called tool — use with care to avoid re-entrancy. + +**Lifecycle:** Hook registrations are tied to the `.scm` file. When a file is hot-reloaded +or deleted, its hooks are automatically cleared and re-evaluated from the fresh source. + +## Hook Execution +When a hook fires, registered plugins are called with: + +- `CHIBI_HOOK` env var - Hook point name (e.g., "pre_message") +- stdin - JSON data about the event ## Example Hook Plugin A minimal hook plugin that logs events: diff --git a/justfile b/justfile index 96915edd8..c7bd8a837 100644 --- a/justfile +++ b/justfile @@ -269,6 +269,12 @@ install: # === Documentation === +# Regenerate the hook reference section in docs/hooks.md from HOOK_METADATA. +# Must be run after changing HOOK_METADATA in hooks.rs. +# The test `test_hooks_docs_markdown_freshness` fails if docs/hooks.md is stale. +generate-docs: + cargo test -p chibi-core --test generate_docs -- --nocapture + # Build and open documentation locally rustdoc: cargo doc --no-deps --open From 2cb8239139ba74f0cc4fa258772f181585e11a4b Mon Sep 17 00:00:00 2001 From: fey Date: Sun, 15 Mar 2026 15:42:29 +0000 Subject: [PATCH 2/2] fix: valid JSON in generated hooks.md + category validation test - json_block() now places commas after values (before // comments) so generated JSON blocks are syntactically valid once comments stripped; previously commas were appended after the comment text - replace split_once description-first-word heuristic for string examples with "..." placeholder (consistent with return fields, avoids corpus strings like "file_head," leaking into example values) - add test_hook_metadata_categories_valid: asserts every HOOK_METADATA entry's category appears in generate_hooks_markdown's category_order, preventing silent category drop on typo/new category - add explanatory comment to HARNESS_DOCS_MODULE explaining the intentional top-level binding re-export pattern (no local definitions) - regenerate docs/hooks.md from fixed generator --- crates/chibi-core/src/tools/hooks.rs | 99 ++++++--- crates/chibi-core/src/tools/synthesised.rs | 4 + docs/hooks.md | 234 ++++++++++----------- 3 files changed, 185 insertions(+), 152 deletions(-) diff --git a/crates/chibi-core/src/tools/hooks.rs b/crates/chibi-core/src/tools/hooks.rs index 85aeeb5a0..71388e605 100644 --- a/crates/chibi-core/src/tools/hooks.rs +++ b/crates/chibi-core/src/tools/hooks.rs @@ -1290,48 +1290,42 @@ pub fn generate_hooks_markdown() -> String { out.push('\n'); out.push_str(&format!("### {key}\n\n")); + // Emit a JSON block for a slice of FieldMeta entries. + // Commas appear after the value (before the // comment) so the output + // is syntactically valid JSON once comments are stripped. The last + // field has no comma. String fields use "..." as a placeholder; + // the description comment provides the semantic context. + fn json_block(fields: &[FieldMeta]) -> String { + let last = fields.len().saturating_sub(1); + let lines: Vec = fields + .iter() + .enumerate() + .map(|(i, f)| { + let example = match f.typ { + "string" => "\"...\"".to_string(), + "number" => "0".to_string(), + "bool" => "false".to_string(), + "array" => "[]".to_string(), + "object" => "{}".to_string(), + _ => "null".to_string(), + }; + let comma = if i < last { "," } else { "" }; + format!(" \"{}\": {}{} // {}", f.name, example, comma, f.description) + }) + .collect(); + format!("```json\n{{\n{}\n}}\n```\n", lines.join("\n")) + } + // Payload JSON block if meta.payload_fields.is_empty() { out.push_str("Payload: (empty)\n"); } else { - out.push_str("```json\n{\n"); - for f in meta.payload_fields { - let example = match f.typ { - "string" => format!( - "\"{}\"", - f.description.split_once(' ').map(|x| x.0).unwrap_or(f.name) - ), - "number" => "0".to_string(), - "bool" => "false".to_string(), - "array" => "[]".to_string(), - "object" => "{}".to_string(), - _ => "null".to_string(), - }; - out.push_str(&format!( - " \"{}\": {} // {}\n", - f.name, example, f.description - )); - } - out.push_str("}\n```\n"); + out.push_str(&json_block(meta.payload_fields)); } if !meta.return_fields.is_empty() { - out.push_str("\n**Can return:**\n```json\n{\n"); - for f in meta.return_fields { - let example = match f.typ { - "string" => "\"...\"".to_string(), - "number" => "0".to_string(), - "bool" => "true".to_string(), - "array" => "[]".to_string(), - "object" => "{}".to_string(), - _ => "null".to_string(), - }; - out.push_str(&format!( - " \"{}\": {} // {}\n", - f.name, example, f.description - )); - } - out.push_str("}\n```\n"); + out.push_str("\n**Can return:**\n"); + out.push_str(&json_block(meta.return_fields)); } if !meta.notes.is_empty() { @@ -1660,6 +1654,41 @@ mod tests { ); } + #[cfg(feature = "synthesised-tools")] + #[test] + fn test_hook_metadata_categories_valid() { + // Every HOOK_METADATA entry's category must appear in generate_hooks_markdown's + // category_order. Without this, a mismatched category string is silently + // dropped from the generated markdown. + let category_order = [ + "session", + "message", + "system_prompt", + "tool", + "api", + "agentic", + "file_permission", + "url_security", + "agent", + "cache", + "message_delivery", + "index", + "vfs_write", + "context", + ]; + let valid: std::collections::HashSet<&str> = category_order.iter().copied().collect(); + let mut bad = Vec::new(); + for meta in HOOK_METADATA { + if !valid.contains(meta.category) { + bad.push(format!("{}: {:?}", meta.point.as_ref(), meta.category)); + } + } + assert!( + bad.is_empty(), + "HOOK_METADATA entries with unknown category (will be silently dropped from generated docs): {bad:?}" + ); + } + #[cfg(feature = "synthesised-tools")] #[test] fn test_generate_hooks_docs_alist_contains_all_hooks() { diff --git a/crates/chibi-core/src/tools/synthesised.rs b/crates/chibi-core/src/tools/synthesised.rs index 0b415baaf..2b2975cb2 100644 --- a/crates/chibi-core/src/tools/synthesised.rs +++ b/crates/chibi-core/src/tools/synthesised.rs @@ -313,6 +313,10 @@ pub(crate) const HARNESS_DOCS_MODULE: &str = r#" (define-library (harness docs) (import (scheme base)) (export hooks-docs harness-tools-docs) + ;; Both bindings are pre-defined at top level by HARNESS_PREAMBLE (evaluated + ;; before module registration), so this library intentionally re-exports + ;; top-level bindings without defining them locally. Same pattern as + ;; HARNESS_HOOKS_MODULE re-exporting the top-level `register-hook`. (begin #t)) "#; diff --git a/docs/hooks.md b/docs/hooks.md index d6429153b..a1d1dc856 100644 --- a/docs/hooks.md +++ b/docs/hooks.md @@ -114,8 +114,8 @@ Chibi supports a hooks system that allows plugins to register for lifecycle even ```json { - "chibi_home": "chibi" // chibi home directory path - "project_root": "project" // project root directory path + "chibi_home": "...", // chibi home directory path + "project_root": "...", // project root directory path "tool_count": 0 // number of loaded tools } ``` @@ -130,9 +130,9 @@ Payload: (empty) ```json { - "prompt": "the" // the user's prompt - "context_name": "active" // active context name - "summary": "conversation" // conversation summary + "prompt": "...", // the user's prompt + "context_name": "...", // active context name + "summary": "..." // conversation summary } ``` @@ -147,9 +147,9 @@ Payload: (empty) ```json { - "prompt": "original" // original prompt - "response": "LLM's" // LLM's response - "context_name": "active" // active context name + "prompt": "...", // original prompt + "response": "...", // LLM's response + "context_name": "..." // active context name } ``` @@ -157,8 +157,8 @@ Payload: (empty) ```json { - "context_name": "active" // active context name - "summary": "conversation" // conversation summary + "context_name": "...", // active context name + "summary": "...", // conversation summary "flock_goals": [] // array of {flock, goals} objects } ``` @@ -176,8 +176,8 @@ Payload: (empty) ```json { - "context_name": "active" // active context name - "summary": "conversation" // conversation summary + "context_name": "...", // active context name + "summary": "...", // conversation summary "flock_goals": [] // array of {flock, goals} objects } ``` @@ -195,7 +195,7 @@ Payload: (empty) ```json { - "tool_name": "name" // name of the tool being called + "tool_name": "...", // name of the tool being called "arguments": {} // tool arguments object } ``` @@ -203,8 +203,8 @@ Payload: (empty) **Can return:** ```json { - "arguments": {} // modified arguments - "block": true // set true to block execution + "arguments": {}, // modified arguments + "block": false, // set true to block execution "message": "..." // message shown when blocked } ``` @@ -213,9 +213,9 @@ Payload: (empty) ```json { - "tool_name": "name" // name of the tool that ran - "arguments": {} // tool arguments object - "result": "tool" // tool output + "tool_name": "...", // name of the tool that ran + "arguments": {}, // tool arguments object + "result": "...", // tool output "cached": false // true if output was cached due to size } ``` @@ -224,17 +224,17 @@ Payload: (empty) ```json { - "tool_name": "name" // name of the tool that ran - "arguments": {} // tool arguments object - "output": "raw" // raw tool output + "tool_name": "...", // name of the tool that ran + "arguments": {}, // tool arguments object + "output": "..." // raw tool output } ``` **Can return:** ```json { - "output": "..." // modified output - "block": true // set true to replace output entirely + "output": "...", // modified output + "block": false, // set true to replace output entirely "message": "..." // replacement message shown to LLM when blocked } ``` @@ -243,10 +243,10 @@ Payload: (empty) ```json { - "tool_name": "name" // name of the tool that ran - "arguments": {} // tool arguments object - "output": "original" // original output after pre_tool_output modifications - "final_output": "what" // what the LLM will see (may be truncated if cached) + "tool_name": "...", // name of the tool that ran + "arguments": {}, // tool arguments object + "output": "...", // original output after pre_tool_output modifications + "final_output": "...", // what the LLM will see (may be truncated if cached) "cached": false // true if output was cached } ``` @@ -255,9 +255,9 @@ Payload: (empty) ```json { - "context_name": "active" // active context name - "tools": [] // array of {name, type} tool objects - "fuel_remaining": 0 // remaining tool-call budget + "context_name": "...", // active context name + "tools": [], // array of {name, type} tool objects + "fuel_remaining": 0, // remaining tool-call budget "fuel_total": 0 // total fuel budget } ``` @@ -265,7 +265,7 @@ Payload: (empty) **Can return:** ```json { - "exclude": [] // tool names to remove (union across hooks) + "exclude": [], // tool names to remove (union across hooks) "include": [] // allowlist: only these tools remain (intersection across hooks) } ``` @@ -276,9 +276,9 @@ Payload: (empty) ```json { - "context_name": "active" // active context name - "request_body": {} // full request body (model, messages, tools, etc.) - "fuel_remaining": 0 // remaining tool-call budget + "context_name": "...", // active context name + "request_body": {}, // full request body (model, messages, tools, etc.) + "fuel_remaining": 0, // remaining tool-call budget "fuel_total": 0 // total fuel budget } ``` @@ -296,18 +296,18 @@ Payload: (empty) ```json { - "context_name": "active" // active context name - "fuel_remaining": 0 // remaining tool-call budget - "fuel_total": 0 // total fuel budget - "current_fallback": "current" // current fallback target (call_agent or call_user) - "message": "user" // user message for this loop + "context_name": "...", // active context name + "fuel_remaining": 0, // remaining tool-call budget + "fuel_total": 0, // total fuel budget + "current_fallback": "...", // current fallback target (call_agent or call_user) + "message": "..." // user message for this loop } ``` **Can return:** ```json { - "fallback": "..." // override fallback: call_agent or call_user + "fallback": "...", // override fallback: call_agent or call_user "fuel": 0 // set fuel_remaining to this value } ``` @@ -316,10 +316,10 @@ Payload: (empty) ```json { - "context_name": "active" // active context name - "fuel_remaining": 0 // remaining tool-call budget - "fuel_total": 0 // total fuel budget - "current_fallback": "current" // current fallback target + "context_name": "...", // active context name + "fuel_remaining": 0, // remaining tool-call budget + "fuel_total": 0, // total fuel budget + "current_fallback": "...", // current fallback target "tool_calls": [] // array of {name, arguments} for tools that ran } ``` @@ -327,7 +327,7 @@ Payload: (empty) **Can return:** ```json { - "fallback": "..." // override fallback: call_agent or call_user + "fallback": "...", // override fallback: call_agent or call_user "fuel_delta": 0 // adjust fuel by this amount (positive adds, negative consumes, saturating) } ``` @@ -338,15 +338,15 @@ Payload: (empty) ```json { - "tool_name": "file_head," // file_head, file_tail, or file_lines - "path": "absolute" // absolute path being read + "tool_name": "...", // file_head, file_tail, or file_lines + "path": "..." // absolute path being read } ``` **Can return:** ```json { - "denied": true // set true to block the read + "denied": false, // set true to block the read "reason": "..." // reason shown when denied } ``` @@ -357,16 +357,16 @@ Payload: (empty) ```json { - "tool_name": "write_file" // write_file or file_edit - "path": "absolute" // absolute path being written - "content": "file" // file content (null for file_edit) + "tool_name": "...", // write_file or file_edit + "path": "...", // absolute path being written + "content": "..." // file content (null for file_edit) } ``` **Can return:** ```json { - "denied": true // set true to block the write + "denied": false, // set true to block the write "reason": "..." // reason shown when denied } ``` @@ -377,15 +377,15 @@ Payload: (empty) ```json { - "tool_name": "tool_name" // shell_exec - "command": "shell" // shell command string + "tool_name": "...", // shell_exec + "command": "..." // shell command string } ``` **Can return:** ```json { - "denied": true // set true to block execution + "denied": false, // set true to block execution "reason": "..." // reason shown when denied } ``` @@ -396,17 +396,17 @@ Payload: (empty) ```json { - "tool_name": "tool_name" // fetch_url - "url": "URL" // URL being fetched - "safety": "safety" // sensitive - "reason": "loopback" // loopback address, private network address, cloud metadata endpoint, or could not parse URL + "tool_name": "...", // fetch_url + "url": "...", // URL being fetched + "safety": "...", // sensitive + "reason": "..." // loopback address, private network address, cloud metadata endpoint, or could not parse URL } ``` **Can return:** ```json { - "denied": true // set true to block the fetch + "denied": false, // set true to block the fetch "reason": "..." // reason shown when denied } ``` @@ -417,10 +417,10 @@ Payload: (empty) ```json { - "system_prompt": "system" // system prompt for sub-agent - "input": "input" // input content to process - "model": "model" // model identifier - "temperature": 0 // sampling temperature + "system_prompt": "...", // system prompt for sub-agent + "input": "...", // input content to process + "model": "...", // model identifier + "temperature": 0, // sampling temperature "max_tokens": 0 // max tokens for response } ``` @@ -428,8 +428,8 @@ Payload: (empty) **Can return:** ```json { - "response": "..." // pre-computed response to use instead of LLM call - "block": true // set true to block the sub-agent call + "response": "...", // pre-computed response to use instead of LLM call + "block": false, // set true to block the sub-agent call "message": "..." // message shown when blocked } ``` @@ -438,10 +438,10 @@ Payload: (empty) ```json { - "system_prompt": "system" // system prompt used - "input": "input" // input content - "model": "model" // model identifier - "response": "sub-agent's" // sub-agent's response + "system_prompt": "...", // system prompt used + "input": "...", // input content + "model": "...", // model identifier + "response": "..." // sub-agent's response } ``` @@ -449,10 +449,10 @@ Payload: (empty) ```json { - "tool_name": "tool" // tool whose output is being cached - "arguments": {} // tool arguments - "content": "full" // full output content - "char_count": 0 // character count of content + "tool_name": "...", // tool whose output is being cached + "arguments": {}, // tool arguments + "content": "...", // full output content + "char_count": 0, // character count of content "line_count": 0 // line count of content } ``` @@ -468,9 +468,9 @@ Payload: (empty) ```json { - "tool_name": "tool" // tool whose output was cached - "cache_id": "filename" // filename under vfs:///sys/tool_cache// - "output_size": 0 // size of cached output in bytes + "tool_name": "...", // tool whose output was cached + "cache_id": "...", // filename under vfs:///sys/tool_cache// + "output_size": 0, // size of cached output in bytes "preview_size": 0 // size of preview shown to LLM } ``` @@ -481,17 +481,17 @@ Payload: (empty) ```json { - "from": "sending" // sending context name - "to": "recipient" // recipient context name - "content": "message" // message content - "context_name": "active" // active context name + "from": "...", // sending context name + "to": "...", // recipient context name + "content": "...", // message content + "context_name": "..." // active context name } ``` **Can return:** ```json { - "delivered": true // set true to claim delivery was handled + "delivered": false, // set true to claim delivery was handled "via": "..." // delivery mechanism name (for logging) } ``` @@ -500,11 +500,11 @@ Payload: (empty) ```json { - "from": "sending" // sending context name - "to": "recipient" // recipient context name - "content": "message" // message content - "context_name": "active" // active context name - "delivery_result": "delivery" // delivery outcome description + "from": "...", // sending context name + "to": "...", // recipient context name + "content": "...", // message content + "context_name": "...", // active context name + "delivery_result": "..." // delivery outcome description } ``` @@ -512,9 +512,9 @@ Payload: (empty) ```json { - "path": "relative" // relative path of indexed file - "lang": "detected" // detected language - "symbol_count": 0 // number of symbols indexed + "path": "...", // relative path of indexed file + "lang": "...", // detected language + "symbol_count": 0, // number of symbols indexed "ref_count": 0 // number of references indexed } ``` @@ -523,10 +523,10 @@ Payload: (empty) ```json { - "tool_name": "write_file" // write_file or file_edit - "path": "VFS" // VFS path being written - "content": "new" // new content (null for file_edit) - "caller": "context" // context initiating the write + "tool_name": "...", // write_file or file_edit + "path": "...", // VFS path being written + "content": "...", // new content (null for file_edit) + "caller": "..." // context initiating the write } ``` @@ -536,9 +536,9 @@ Payload: (empty) ```json { - "tool_name": "write_file" // write_file or file_edit - "path": "VFS" // VFS path that was written - "caller": "context" // context that initiated the write + "tool_name": "...", // write_file or file_edit + "path": "...", // VFS path that was written + "caller": "..." // context that initiated the write } ``` @@ -548,9 +548,9 @@ Payload: (empty) ```json { - "context_name": "context" // context being cleared - "message_count": 0 // number of messages before clear - "summary": "existing" // existing conversation summary + "context_name": "...", // context being cleared + "message_count": 0, // number of messages before clear + "summary": "..." // existing conversation summary } ``` @@ -558,9 +558,9 @@ Payload: (empty) ```json { - "context_name": "context" // context that was cleared - "message_count": 0 // message count before clear - "summary": "summary" // summary before clear + "context_name": "...", // context that was cleared + "message_count": 0, // message count before clear + "summary": "..." // summary before clear } ``` @@ -568,9 +568,9 @@ Payload: (empty) ```json { - "context_name": "context" // context being compacted - "message_count": 0 // number of messages before compact - "summary": "conversation" // conversation summary + "context_name": "...", // context being compacted + "message_count": 0, // number of messages before compact + "summary": "..." // conversation summary } ``` @@ -578,9 +578,9 @@ Payload: (empty) ```json { - "context_name": "context" // context that was compacted - "message_count": 0 // message count before compact - "summary": "conversation" // conversation summary + "context_name": "...", // context that was compacted + "message_count": 0, // message count before compact + "summary": "..." // conversation summary } ``` @@ -588,10 +588,10 @@ Payload: (empty) ```json { - "context_name": "context" // context being compacted - "message_count": 0 // total message count - "non_system_count": 0 // non-system message count - "summary": "conversation" // conversation summary + "context_name": "...", // context being compacted + "message_count": 0, // total message count + "non_system_count": 0, // non-system message count + "summary": "..." // conversation summary } ``` @@ -599,10 +599,10 @@ Payload: (empty) ```json { - "context_name": "context" // context that was compacted - "message_count": 0 // message count after archiving - "messages_archived": 0 // number of messages archived - "summary": "updated" // updated summary + "context_name": "...", // context that was compacted + "message_count": 0, // message count after archiving + "messages_archived": 0, // number of messages archived + "summary": "..." // updated summary } ```