feat: plugin runtime primitives and agent foundation hooks (v2)#199
feat: plugin runtime primitives and agent foundation hooks (v2)#199dgarson merged 6 commits intodgarson/forkfrom
Conversation
…ta limits) Introduces three first-class runtime namespaces available to every plugin via runtime.kv, runtime.cron, and runtime.quota. KV store (runtime.kv): scoped key-value persistence backed by the agent state directory. Plugins get full CRUD + TTL expiry without touching the filesystem directly. Critical for extensions that need durable config or cross-invocation memory (e.g. budget ledgers, mail stores, policy caches). Cron scheduler (runtime.cron): declarative periodic tasks registered by name. Runs inside the gateway process on the given cron expression; scheduling survives gateway restarts because registrations are replayed at startup. Enables extensions like the event-ledger retention sweep or the observability health-check heartbeat. Quota system (runtime.quota): per-scope token/request/cost counters with configurable windows and soft/hard limits. Plugins can increment usage and check headroom before expensive operations -- the budget-manager extension is built entirely on top of this. Type namespaces are extracted to dedicated files (types.kv.ts, types.cron.ts, types.quota.ts, types.agents.ts, types.gateway.ts, types.sessions.ts) so the main runtime/types.ts stays readable. All three implementations have unit tests. pnpm-lock.yaml is updated for the otel/opentelemetry-api dependency pulled in by the observability extension. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…tion Adds four new plugin type modules that define the full interception surface plugins can register against: types.hooks.primitives.ts -- before_agent_run and before_message_route hooks. before_agent_run fires before every agent turn and lets plugins inject extra context, modify the effective config, or abort the run with a structured response. before_message_route fires at inbound routing time so plugins can redirect or suppress messages before any agent is activated. types.hooks.subagent-spawn.ts -- before_subagent_spawn hook. Called just before a subagent is forked, giving plugins the ability to deny, reroute, or annotate the spawn (e.g. budget checks, policy enforcement). types.hooks.tool-mutation.ts -- after_tool_call hook. Called with the raw tool result; plugins can rewrite or enrich the result before the agent sees it (useful for observability spans, sanitization, or result caching). types.prompt-sections.ts -- PromptSectionBuilder API. Plugins register named prompt sections with a slot (prepend / append / replace:*), a priority, and an optional condition predicate. The agent runner collects and injects them at system-prompt assembly time (see system-prompt.plugin-sections.ts). hooks.ts is updated to dispatch the four new hook types through the plugin registry. registry.ts gains registerHook, registerPromptSection, and the corresponding query helpers. plugin-sdk/index.ts re-exports everything so plugin authors get a single entry-point import. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
… built-in tool system-prompt.plugin-sections.ts introduces collectPluginPromptSections(), which queries the plugin registry for registered PromptSectionBuilders, runs them with the current session context, and returns sorted+filtered sections ready for injection. Sections support three slots: prepend (before the core prompt), append (after it), and replace:<slot-name> (swap out a named block). Only the highest-priority registration wins for replace slots; collisions are logged as warnings. This is the mechanism that lets extensions like ocx-orchestration inject a live sprint context or ocx-routing-policy inject model instructions without forking the system-prompt builder. system-prompt.ts is updated to accept pluginSections and splice them in at the appropriate positions in the final assembled prompt. The change is purely additive -- callers that pass no sections get the same output as before. session-score-tool.ts adds the session_score built-in agent tool (tool #10). Agents call it to emit a structured self-quality score (0.0-1.0) with a rubric label, optional tags, and a free-text rationale note. The score is forwarded to the diagnostic event pipeline as a session.score event, where the observability extension can pick it up, tag it with OTel attributes, and surface it in dashboards. No LLM call is made; the tool is a lightweight event emitter so latency impact is negligible. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…unner improvements
pi-tool-definition-adapter.ts now wraps each tool's execute function to fire
the before_tool_call plugin hook before dispatch. This lets plugins (such as
ocx-budget-manager) intercept any tool call, inspect its arguments, and either
allow, modify, or deny it -- without the agent having any awareness. The
after_tool_call hook (types.hooks.tool-mutation.ts) is wired at the same
layer so result rewriting is symmetric.
pi-tools.before-tool-call.ts implements the dispatch logic: it walks the
hook registry, calls each registered handler in priority order, and surfaces
a combined allow/deny decision. An integration test covers the allow,
deny-with-message, and result-mutate paths end-to-end.
Embedded runner changes (pi-embedded-runner/run.ts, attempt.ts, types.ts):
- pluginSections is threaded through RunAttemptContext so the assembled
system prompt picks up plugin sections collected before the run starts.
- compact.ts and tool-split.ts receive minor type-safety fixes exposed by
the stricter RunAttemptContext shape.
- system-prompt.ts in the runner now forwards pluginSections into the
shared buildAgentSystemPrompt builder.
pi-embedded-subscribe.* gains the tool-call plumbing so subscribe-mode
sessions (used by background agents) apply the same before/after hooks.
sessions-spawn-tool.ts updates the spawn payload to include agentRuntimeConfig
so spawned subagents inherit the caller's model/tool overrides unless
explicitly overridden.
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
…subagents subagent-spawn.ts now fires the before_subagent_spawn plugin hook before forking. Any registered plugin can inspect the spawn parameters (requested agent ID, tool payload, parent session context) and return deny with a reason -- the spawn is aborted and the reason is surfaced to the calling agent as a tool error. This is the enforcement point used by ocx-budget-manager to block spawns when a session is over budget, and by ocx-routing-policy to reroute spawns to a lower-cost model tier. cleanup-hook-gate.ts introduces an AsyncLocalStorage-based scope flag. When an agent's error-cleanup hook runs, it wraps its execution in runInCleanupHookScope(). isInCleanupHookScope() is checked at spawn time: if true, the spawn is rejected immediately without calling plugin hooks. This prevents runaway spawn chains triggered inside cleanup logic -- an important safety property when cleanup hooks themselves use agent tools. Tests (cleanup-hook-gate.test.ts, cleanup-hook-spawn-gate.test.ts, subagent-spawn.cleanup-gate.test.ts) verify that: - Normal spawns pass through all hook handlers. - A deny result from any hook aborts the spawn. - Spawns attempted inside a cleanup scope are rejected unconditionally. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
resolve-route.ts now accepts an optional hookRunner so the routing layer can dispatch the before_message_route hook without coupling to the plugin registry directly. The hook receives the resolved agent route and lets plugins redirect to a different agent, suppress the message entirely (returning a canned response), or annotate the routing context with extra metadata carried into the agent run. auto-reply/dispatch.ts is updated to pass the hookRunner through to resolveAgentRoute so that the hook fires at the right point in the pipeline: after channel-specific routing (pairing, allowlists, guild/team bindings) is resolved but before the agent runner is started. This ordering is important because the hook sees the authoritative resolved agent ID, not an intermediate candidate. dispatch.test.ts adds coverage for the suppress and redirect paths to ensure a plugin returning a deny result terminates dispatch without starting an agent run. channel-plugins.ts is added to test-utils to provide a reusable channel plugin fixture that registers mock hook handlers, reducing boilerplate in routing and dispatch tests. Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
There was a problem hiding this comment.
💡 Codex Review
Here are some automated review suggestions for this pull request.
Reviewed commit: 4192dabebb
ℹ️ About Codex in GitHub
Codex has been enabled to automatically review pull requests in this repo. Reviews are triggered when you
- Open a pull request for review
- Mark a draft as ready
- Comment "@codex review".
If Codex has suggestions, it will comment; otherwise it will react with 👍.
When you sign up for Codex through ChatGPT, Codex can also answer questions or update the PR, like "@codex address that feedback".
| } catch (hookErr) { | ||
| log.warn(`before_agent_run hook failed: ${String(hookErr)}`); | ||
| } |
There was a problem hiding this comment.
Block run when before_agent_run hook errors
before_agent_run is registered as a fail-closed hook in src/plugins/hooks.ts, but this catch path only logs and continues, so a thrown policy-hook error (for example, quota/HITL backend failure) lets the agent run proceed instead of denying safely. In production, that turns transient hook failures into policy bypasses.
Useful? React with 👍 / 👎.
| } catch (hookErr) { | ||
| // Non-critical — log and allow spawn to proceed. | ||
| console.warn(`[before_subagent_spawn] hook error: ${String(hookErr)}`); | ||
| } |
There was a problem hiding this comment.
Reject subagent spawn when policy hook fails
before_subagent_spawn is also marked fail-closed in the hook runner, but this block swallows thrown hook errors and allows spawning to continue. If a spawn-governance hook throws (e.g., external policy check outage), restricted subagent spawns are executed anyway rather than being denied.
Useful? React with 👍 / 👎.
| } | ||
|
|
||
| const result = await tool.execute(resolvedToolCallId, executeParams, signal, onUpdate); | ||
| const result = await tool.execute(toolCallId, executeParams, signal, onUpdate); |
There was a problem hiding this comment.
Repair malformed tool args before executing tools
This execution path now calls tool.execute directly with incoming params, but the previous validation/repair layer was removed from the adapter. As a result, malformed or stringified tool arguments that were previously repaired now reach tools uncorrected, causing avoidable tool-call failures for the same model outputs.
Useful? React with 👍 / 👎.
Recreated from PR #190 with clean ancestry.
Features
Changes
This is a minimal re-application of the core feature commits from PR #190, excluding the cherry-picked main fixes that caused merge conflicts.
Original PR #190 had 3585 files changed with many cherry-picked commits. This version contains only the essential feature changes.
Commits included: