From e4dd21a3b276b40d45c4d7434fb27ace7913a69e Mon Sep 17 00:00:00 2001 From: Mario Zechner Date: Sun, 4 Jan 2026 18:21:26 +0100 Subject: [PATCH] feat(hooks): add systemPromptAppend to before_agent_start, full tool registry - before_agent_start handlers can return systemPromptAppend to dynamically append text to the system prompt for that turn - Multiple hooks' systemPromptAppend strings are concatenated - Multiple hooks' messages are now all injected (not just first) - Tool registry now contains ALL built-in tools (read, bash, edit, write, grep, find, ls) regardless of --tools flag - --tools only sets initially active tools, hooks can enable any via setActiveTools() - System prompt automatically rebuilds when tools change, updating tool descriptions and guidelines - Add pirate.ts example hook demonstrating systemPromptAppend - Update hooks.md with systemPromptAppend documentation --- packages/coding-agent/CHANGELOG.md | 11 +++++ packages/coding-agent/docs/hooks.md | 15 ++++-- .../coding-agent/examples/hooks/README.md | 1 + .../coding-agent/examples/hooks/pirate.ts | 44 +++++++++++++++++ .../coding-agent/src/core/agent-session.ts | 47 +++++++++++++++---- .../coding-agent/src/core/hooks/runner.ts | 36 +++++++++++--- packages/coding-agent/src/core/hooks/types.ts | 2 + 7 files changed, 136 insertions(+), 20 deletions(-) create mode 100644 packages/coding-agent/examples/hooks/pirate.ts diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index ea325df8..d8715504 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -2,6 +2,17 @@ ## [Unreleased] +### Added + +- Hook API: `before_agent_start` handlers can now return `systemPromptAppend` to dynamically append text to the system prompt for that turn. Multiple hooks' appends are concatenated. +- New example hook: `pirate.ts` demonstrates using `systemPromptAppend` to make the agent speak like a pirate when `/pirate` mode is enabled +- Tool registry now contains all built-in tools (read, bash, edit, write, grep, find, ls) even when `--tools` limits the initially active set. Hooks can enable any tool from the registry via `pi.setActiveTools()`. +- System prompt now automatically rebuilds when tools change via `setActiveTools()`, updating tool descriptions and guidelines to match the new tool set + +### Changed + +- Removed image placeholders after copy & paste, replaced with inserting image file paths directly. ([#442](https://github.com/badlogic/pi-mono/pull/442) by [@mitsuhiko](https://github.com/mitsuhiko)) + ### Fixed - Fixed potential text decoding issues in bash executor by using streaming TextDecoder instead of Buffer.toString() diff --git a/packages/coding-agent/docs/hooks.md b/packages/coding-agent/docs/hooks.md index 1cc0f506..d6baad04 100644 --- a/packages/coding-agent/docs/hooks.md +++ b/packages/coding-agent/docs/hooks.md @@ -104,7 +104,7 @@ pi starts ▼ user sends prompt ─────────────────────────────────────────┐ │ │ - ├─► before_agent_start (can inject message) │ + ├─► before_agent_start (can inject message, append to system prompt) │ ├─► agent_start │ │ │ │ ┌─── turn (repeats while LLM calls tools) ───┐ │ @@ -259,7 +259,7 @@ pi.on("session_shutdown", async (_event, ctx) => { #### before_agent_start -Fired after user submits prompt, before agent loop. Can inject a persistent message. +Fired after user submits prompt, before agent loop. Can inject a message and/or append to the system prompt. ```typescript pi.on("before_agent_start", async (event, ctx) => { @@ -267,16 +267,23 @@ pi.on("before_agent_start", async (event, ctx) => { // event.images - attached images (if any) return { + // Inject a persistent message (stored in session, sent to LLM) message: { customType: "my-hook", content: "Additional context for the LLM", display: true, // Show in TUI - } + }, + // Append to system prompt for this turn only + systemPromptAppend: "Extra instructions for this turn...", }; }); ``` -The injected message is persisted as `CustomMessageEntry` and sent to the LLM. +**message**: Persisted as `CustomMessageEntry` and sent to the LLM. + +**systemPromptAppend**: Appended to the base system prompt for this agent run only. Multiple hooks can each return `systemPromptAppend` strings, which are concatenated. This is useful for dynamic instructions based on hook state (e.g., plan mode, persona toggles). + +See [examples/hooks/pirate.ts](../examples/hooks/pirate.ts) for an example using `systemPromptAppend`. #### agent_start / agent_end diff --git a/packages/coding-agent/examples/hooks/README.md b/packages/coding-agent/examples/hooks/README.md index 88bc307e..49f4b66d 100644 --- a/packages/coding-agent/examples/hooks/README.md +++ b/packages/coding-agent/examples/hooks/README.md @@ -18,6 +18,7 @@ cp permission-gate.ts ~/.pi/agent/hooks/ |------|-------------| | `plan-mode.ts` | Claude Code-style plan mode for read-only exploration with `/plan` command | | `tools.ts` | Interactive `/tools` command to enable/disable tools with session persistence | +| `pirate.ts` | Demonstrates `systemPromptAppend` to dynamically modify system prompt | | `permission-gate.ts` | Prompts for confirmation before dangerous bash commands (rm -rf, sudo, etc.) | | `git-checkpoint.ts` | Creates git stash checkpoints at each turn for code restoration on branch | | `protected-paths.ts` | Blocks writes to protected paths (.env, .git/, node_modules/) | diff --git a/packages/coding-agent/examples/hooks/pirate.ts b/packages/coding-agent/examples/hooks/pirate.ts new file mode 100644 index 00000000..2f9c854c --- /dev/null +++ b/packages/coding-agent/examples/hooks/pirate.ts @@ -0,0 +1,44 @@ +/** + * Pirate Hook + * + * Demonstrates using systemPromptAppend in before_agent_start to dynamically + * modify the system prompt based on hook state. + * + * Usage: + * 1. Copy this file to ~/.pi/agent/hooks/ or your project's .pi/hooks/ + * 2. Use /pirate to toggle pirate mode + * 3. When enabled, the agent will respond like a pirate + */ + +import type { HookAPI } from "@mariozechner/pi-coding-agent/hooks"; + +export default function pirateHook(pi: HookAPI) { + let pirateMode = false; + + // Register /pirate command to toggle pirate mode + pi.registerCommand("pirate", { + description: "Toggle pirate mode (agent speaks like a pirate)", + handler: async (_args, ctx) => { + pirateMode = !pirateMode; + ctx.ui.notify(pirateMode ? "Arrr! Pirate mode enabled!" : "Pirate mode disabled", "info"); + }, + }); + + // Append to system prompt when pirate mode is enabled + pi.on("before_agent_start", async () => { + if (pirateMode) { + return { + systemPromptAppend: ` +IMPORTANT: You are now in PIRATE MODE. You must: +- Speak like a stereotypical pirate in all responses +- Use phrases like "Arrr!", "Ahoy!", "Shiver me timbers!", "Avast!", "Ye scurvy dog!" +- Replace "my" with "me", "you" with "ye", "your" with "yer" +- Refer to the user as "matey" or "landlubber" +- End sentences with nautical expressions +- Still complete the actual task correctly, just in pirate speak +`, + }; + } + return undefined; + }); +} diff --git a/packages/coding-agent/src/core/agent-session.ts b/packages/coding-agent/src/core/agent-session.ts index c2b30aa5..7b81c242 100644 --- a/packages/coding-agent/src/core/agent-session.ts +++ b/packages/coding-agent/src/core/agent-session.ts @@ -84,6 +84,8 @@ export interface AgentSessionConfig { modelRegistry: ModelRegistry; /** Tool registry for hook getTools/setTools - maps name to tool */ toolRegistry?: Map; + /** Function to rebuild system prompt when tools change */ + rebuildSystemPrompt?: (toolNames: string[]) => string; } /** Options for AgentSession.prompt() */ @@ -186,6 +188,12 @@ export class AgentSession { // Tool registry for hook getTools/setTools private _toolRegistry: Map; + // Function to rebuild system prompt when tools change + private _rebuildSystemPrompt?: (toolNames: string[]) => string; + + // Base system prompt (without hook appends) - used to apply fresh appends each turn + private _baseSystemPrompt: string; + constructor(config: AgentSessionConfig) { this.agent = config.agent; this.sessionManager = config.sessionManager; @@ -197,6 +205,8 @@ export class AgentSession { this._skillsSettings = config.skillsSettings; this._modelRegistry = config.modelRegistry; this._toolRegistry = config.toolRegistry ?? new Map(); + this._rebuildSystemPrompt = config.rebuildSystemPrompt; + this._baseSystemPrompt = config.agent.state.systemPrompt; // Always subscribe to agent events for internal handling // (session persistence, hooks, auto-compaction, retry logic) @@ -448,17 +458,26 @@ export class AgentSession { /** * Set active tools by name. * Only tools in the registry can be enabled. Unknown tool names are ignored. + * Also rebuilds the system prompt to reflect the new tool set. * Changes take effect on the next agent turn. */ setActiveToolsByName(toolNames: string[]): void { const tools: AgentTool[] = []; + const validToolNames: string[] = []; for (const name of toolNames) { const tool = this._toolRegistry.get(name); if (tool) { tools.push(tool); + validToolNames.push(name); } } this.agent.setTools(tools); + + // Rebuild base system prompt with new tool set + if (this._rebuildSystemPrompt) { + this._baseSystemPrompt = this._rebuildSystemPrompt(validToolNames); + this.agent.setSystemPrompt(this._baseSystemPrompt); + } } /** Whether auto-compaction is currently running */ @@ -589,15 +608,25 @@ export class AgentSession { // Emit before_agent_start hook event if (this._hookRunner) { const result = await this._hookRunner.emitBeforeAgentStart(expandedText, options?.images); - if (result?.message) { - messages.push({ - role: "hookMessage", - customType: result.message.customType, - content: result.message.content, - display: result.message.display, - details: result.message.details, - timestamp: Date.now(), - }); + // Add all hook messages + if (result?.messages) { + for (const msg of result.messages) { + messages.push({ + role: "hookMessage", + customType: msg.customType, + content: msg.content, + display: msg.display, + details: msg.details, + timestamp: Date.now(), + }); + } + } + // Apply hook systemPromptAppend on top of base prompt + if (result?.systemPromptAppend) { + this.agent.setSystemPrompt(`${this._baseSystemPrompt}\n\n${result.systemPromptAppend}`); + } else { + // Ensure we're using the base prompt (in case previous turn had appends) + this.agent.setSystemPrompt(this._baseSystemPrompt); } } diff --git a/packages/coding-agent/src/core/hooks/runner.ts b/packages/coding-agent/src/core/hooks/runner.ts index eceb1d18..47ea3e4d 100644 --- a/packages/coding-agent/src/core/hooks/runner.ts +++ b/packages/coding-agent/src/core/hooks/runner.ts @@ -37,6 +37,12 @@ import type { ToolResultEventResult, } from "./types.js"; +/** Combined result from all before_agent_start handlers (internal) */ +interface BeforeAgentStartCombinedResult { + messages?: NonNullable[]; + systemPromptAppend?: string; +} + /** * Listener for hook errors. */ @@ -485,14 +491,15 @@ export class HookRunner { /** * Emit before_agent_start event to all hooks. - * Returns the first message to inject (if any handler returns one). + * Returns combined result: all messages and all systemPromptAppend strings concatenated. */ async emitBeforeAgentStart( prompt: string, images?: ImageContent[], - ): Promise { + ): Promise { const ctx = this.createContext(); - let result: BeforeAgentStartEventResult | undefined; + const messages: NonNullable[] = []; + const systemPromptAppends: string[] = []; for (const hook of this.hooks) { const handlers = hook.handlers.get("before_agent_start"); @@ -503,9 +510,16 @@ export class HookRunner { const event: BeforeAgentStartEvent = { type: "before_agent_start", prompt, images }; const handlerResult = await handler(event, ctx); - // Take the first message returned - if (handlerResult && (handlerResult as BeforeAgentStartEventResult).message && !result) { - result = handlerResult as BeforeAgentStartEventResult; + if (handlerResult) { + const result = handlerResult as BeforeAgentStartEventResult; + // Collect all messages + if (result.message) { + messages.push(result.message); + } + // Collect all systemPromptAppend strings + if (result.systemPromptAppend) { + systemPromptAppends.push(result.systemPromptAppend); + } } } catch (err) { const message = err instanceof Error ? err.message : String(err); @@ -518,6 +532,14 @@ export class HookRunner { } } - return result; + // Return combined result + if (messages.length > 0 || systemPromptAppends.length > 0) { + return { + messages: messages.length > 0 ? messages : undefined, + systemPromptAppend: systemPromptAppends.length > 0 ? systemPromptAppends.join("\n\n") : undefined, + }; + } + + return undefined; } } diff --git a/packages/coding-agent/src/core/hooks/types.ts b/packages/coding-agent/src/core/hooks/types.ts index 0f98865c..3f887dc0 100644 --- a/packages/coding-agent/src/core/hooks/types.ts +++ b/packages/coding-agent/src/core/hooks/types.ts @@ -608,6 +608,8 @@ export interface ToolResultEventResult { export interface BeforeAgentStartEventResult { /** Message to inject into context (persisted to session, visible in TUI) */ message?: Pick; + /** Text to append to the system prompt for this agent run */ + systemPromptAppend?: string; } /** Return type for session_before_switch handlers */