Default Behavior Specification
This is the single authoritative document for ai.agent.* default behavior (#735). Code defaults live in packages/im/agent/src/config/zhin-agent-defaults.ts; see the generated config reference for field details.
Defaults by scenario
| Scenario | Trust level | Default behavior |
|---|---|---|
| Owner DM | owner | All enabled tools; no shell while execSecurity: deny; with masterShell: isolated, an isolated bash only in the owner's DM; interactive tasks continue with periodic progress, subject to cancellation, quotas and permissions |
| Group chat | group_member | Inbound queue groupMode: supersede (newer message replaces queued turns, max 5 queued); high-risk external content needs confirmation; outbound content redacted (outboundCheck: redact) |
| Trusted member | trusted_member | Same guardrails as group members plus trusted tools; non-IM ingress (CLI, HTTP) counts as trusted_member |
| Stranger DM | group_member | Treated as a group member; no elevation |
| Unattended schedule | — | maxTokens 32000, maxToolCalls 15, timeoutMs 120000, execPreset: readonly |
The current CLI/Sandbox interactive runtime and its synchronous children have no fixed iteration or ten-minute deadline. Existing compaction, failure/repetition checks and quotas remain. Progress uses progressMinIntervalMs (45 seconds by default), without a three-update cap; progressUpdates: off or progressMaxUpdates: 0 disables it. Quotas remain disabled by default and owners exempt; this is not a strict real-time spending cap, and in-flight requests can exceed the remaining allowance. An already submitted platform progress send may arrive late, but cannot block the final result. Scheduled, unattended, independent background workers and the legacy ZhinAgent.process()/prompt() APIs retain their budgets.
Core defaults
- Loop:
maxIterations 15,timeout 120000ms - Exec:
execSecurity deny,execApprovalMode auto; network is off by default;network.enabled: trueallows HTTPS egress (network.allowedDomainslimits domains; withenabledunset,execPreset: networkstill enables it); turns without network authority are not offered web_fetch / web_search - Sub-agents:
subagentTurnMode off - Tool budget:
extendBy 10,progressWindow 5,maxTotalIterations 40,maxWallClockMs 600000,failureStreakLimit 3,repeatLimit 2 - Usage quota: all 0 (off),
exemptOwner true - Dangerous-tool hard gate: always on; funds tools disabled (
allowFunds false); a call not approved within 5 minutes (approvalTimeoutMs 300000) is denied; at most 3 pending cards per group (maxPendingPerScene 3), beyond that a call is denied outright - Group merge window: on (
inboundQueue.merge.enabled true, group chats only;quietMs 1500/maxWaitMs 6000/maxSenders 5) - Danmaku mode: only for live-stream adapters that declare
liveDanmaku(inboundQueue.live:mode pick-one/windowMs 10000/minIntervalMs 15000; read-only tools, no approval cards) - Compaction:
thresholdRatio 0.7 - History window (ADR 0043): bounded by tokens instead of message count via
ai.context.historyTokens(not anai.agentswitch) — group8000, ordinary private12000, master private24000. Use a number (all scenes) or{ group, private, masterPrivate };0turns the token bound off.ai.context.maxRecentMessagesstays as a message-count upper bound; whichever is hit first wins. The budget is measured on what the model really receives (after projection); the window starts on a turn boundary and only moves, in one block with 25% headroom, when the budget is exceeded (keeps the prompt-cache prefix stable). What drops out stays in storage and in compaction's input. Tool results of finished turns reach the model as their first 200 characters plus…(已省略 N 字); the current turn and storage stay complete. - Request traces:
recordRequestBodies truestores redacted model-input text and tool definitions locally on the Host. Defaults retain at most seven days and 64 MiB in total. Capacity cleanup removes the oldest traces first; an oversized body leaves statistics and an explicit unavailable-body status. Set this tofalseto stop recording new bodies without disabling usage statistics or deleting existing records.
Dangerous-tool hard gate
execApprovalMode decides who answers an ordinary approval (ask asks a person, auto lets the reviewer Agent decide, bypass skips it). The dangerous-tool hard gate is a separate gate, independent of it, and cannot be turned off (ADR 0036 §3):
execApprovalMode approval | Dangerous-tool hard gate | |
|---|---|---|
| Which tools | Tools whose requiresApproval asks for approval; shell / file policy | Tools that declare risk: 'dangerous', the core list, toolHardGate.dangerousTools |
| Who can allow it | A person, the reviewer Agent, or bypass; "allow for this session / always" is possible | Only a fresh human approval of this call, or a card approved in the Console for the same arguments; bypass, the reviewer, the owner role and remembered grants do not count |
| After a denial | The model gets the denial | The call does not run (fail closed); the model only hears "refused, needs human approval, do not retry"; later calls to the same tool or the same dangerous category in this turn are refused without asking again |
| Unattended turns | Denied by policy | Denied, and a Console approval card is created; once approved, the next call in the same session with the same arguments runs once |
The core list is dangerous by category: member governance (kick, mute, set admins, roles, invite), content publishing (group announcements, channel posts, essence / pinned messages, Bilibili comments), funds (red packets, transfers, payments — disabled by default; with allowFunds: true they still need approval), and identity (bot profile, avatar, signature, unbinding accounts).
One more category is judged by the target of the call, not the tool name: changing the host itself, in two tiers. Paths are matched after realpath, ../ collapsing and case folding. Denied outright (no approval card): node_modules/** (any depth), lockfiles (pnpm-lock.yaml, package-lock.json, yarn.lock, bun.lock(b), npm-shrinkwrap.json), the file ZHIN_PLUGIN_LIFECYCLE_FILE points at, and what the file policy already refuses (.env*, .zhin/, root data/, config.json, .npmrc); no role can write them. self hard gate: writing, under the project root, the root config (zhin.config.*, config.yml|yaml), package.json, schema.json, the root plugin entry (package.json#zhin.entry), AGENTS.md, skills/, .agents/skills/, .git/, .pnpmfile.cjs or assistant.profile.yml. Only the project root matches, so a nested plugins/x/package.json is unaffected, and pnpm-workspace.yaml / tsconfig*.json are not managed in this phase. The host injects the project root and the root plugin entry; without an injection the gate matches those names at any depth (over-blocking rather than gating nothing). A bash command that writes one of these paths (redirection, tee, rm/cp/mv, …) gets the same two tiers, and adding, removing or updating dependencies with pnpm / npm / yarn / bun counts as self. A self write can loosen the hard gate, change the code the host runs or trigger a restart, so like every other dangerous category it needs a fresh human approval each time; bypass, the auto reviewer, owner exemption, danger-full-access and remembered approvals never grant it. Read-only queries (member lists, group info) are not gated and are still audited.
Read-only mounts for the isolated shell (#1030 P0-c). The string detection of bash commands above is only the first line (it gives friendlier errors); the enforcement is the mount. When the isolated container mounts the working directory as workspace-write, the existing paths of the two tiers above are overlaid as read-only bind mounts (--mount type=bind,...,readonly), so sed -i, writing through a symlink, mv and rm -rf node_modules all fail (Read-only file system / Device or resource busy), whatever the filesystem access level. Covered inside the mounted directory: the root config, package.json, schema.json, the plugin entry, AGENTS.md, skills/, .agents/skills/, .git/, .pnpmfile.*, assistant.profile.*, lockfiles, ZHIN_PLUGIN_LIFECYCLE_FILE, .env*, .zhin/, .npmrc, the children of data/ other than memory/media, and node_modules directories up to 4 levels deep, at most 64 (the scan never enters node_modules, .git or symlinks). Missing directories (e.g. skills/, node_modules) get a read-only tmpfs so they cannot be created; Docker briefly creates an empty mount point on the host, removed after the run if still empty. Missing files get no placeholder: Docker would leave an empty file on the host, and an empty zhin.config.* / lockfile / .pnpmfile.cjs is more dangerous to hot reload and package managers than the gap, so creating a protected file that does not exist yet is still stopped only by the string detection. Without host-injected paths the conventional names are resolved against the mounted directory itself (over-blocking). Residual risk: danger-full-access (isolation: none) has no container, so nothing can be mounted and only the string detection remains; indirect writes (scripts, python -c, git hooks) may get past it. A symlink that points at a protected file can itself be replaced (its target stays read-only). Use the isolated shell when a hard guarantee matters, not danger-full-access.
Self-change staging area (ADR 0040 §6a, slice A). The host stages agent-drafted changes in .zhin/changes/<id>/ (change.json metadata + files/ drafts + snapshot/ rollback copies); the directory is on P0 list A, so neither the agent's file tools nor its shell can write it. Writable targets are only the plugin convention directories (commands/, middlewares/, handlers/, tools/, components/, skills/, agents/), the root AGENTS.md and skills/**; the root config can only be changed through a field-level configPatch, and a patch that touches a key that relaxes security or widens the tool surface (SELF_CHANGE_SECURITY_CONFIG_KEYS: the ai.agent hard gate / exec approval / exec security / master shell / injection guard / quota exemption / schedule security keys, ai.decisions.approval, ai.workroom.trustedPackPublishers / disclosure, http.token(s), a2a.workroomRemoteExecutors / workroomCallbacks, assistant.home.policy, mcp, ai.mcpServers, ai.githubMcp; tagged x-zhin-config.security in the host schema), or overwrites / removes one of their ancestors, is refused outright; the same check runs again just before apply, so a change approved before the list grew is refused too. Disk use is bounded: on entering a terminal state (applied / rolled_back / rejected / expired / trial_failed) files/ and snapshot/ are deleted at once and only change.json stays, and the whole directory is removed after 14 days; draft / ready / pending changes idle for 24 hours become expired; a failed change (emergency stop) keeps its files until a person marks it handled. One change holds at most 5 MB / 50 files and the whole area at most 50 MB; over the limit a draft is refused before anything is written (change_quota_exceeded). Pruning runs at start-up and then every 6 hours, deletes only directories inside the staging area and never follows symlinks. State changes are written to ai_decision_events with kind=self_change; payloads carry paths and digests only, never file contents or config values.
Drafting a change: change_draft (ADR 0040 §2, slice B). When a user asks to "add a command / change a setting", the agent calls change_draft (risk: safe) with complete file contents and an optional field-level config_patch (values go in value_json as a JSON string); the host validates and stages it — production is not touched and nothing takes effect. It works only in IM conversations and can only revise the caller's own draft (change_id); editing a draft discards its old preview. The host then builds a preview and stores it as plan in change.json: create / modify / delete per file with added and removed line counts (line-based, moved lines not counted); the impact comes from the same InvalidationPlanner used for development file watching (over the current generation's source ownership) — none, generation (the plugin subtrees and capability slots that will hot reload) or process (restart required); a config patch is merged in the same way Console config:patch judges it — host sections (http, database, ai, …) need a restart, plugin sections hot reload that plugin. Without the ownership index the preview is a conservative estimate (files → hot reload, config → restart) and is marked as estimated. A preview is at most 64 KB and counts toward the staging quota. Approval: see slice D below.
Trial run: static checks (ADR 0040 §3, slice C1). Right after change_draft stages a change and builds its preview, the host trial-runs it: draft → trialing → ready (passed) or trial_failed (failed — terminal, staged contents deleted at once). The static checks run in memory and write no temporary files: syntax — .ts / .mts / .cts / .js are parsed with Node's built-in type stripping (the same implementation that loads .ts in production, so strip-only-unsupported syntax such as enum fails too), .tsx / .jsx with esbuild (the same transformer as the tsx loader); default_export — capability files in convention directories must default-export the matching defineCommand / defineMiddleware / defineHandler / defineComponent / defineAgentTool (likewise Skill / Agent private tools at .../tools/<name>/index.ts); forbidden_import — calling bot.$sendMessage directly, importing koa, a plugin importing @zhin.js/kernel, or the removed zhin.js/node / bootstrapNode; skill / agent_manifest — SKILL.md must be accepted by the Skill parser and agent.json must be a JSON object. The result is stored as trial in change.json (bound to the content_hash, each message at most 300 characters); on failure the errors go straight back to the agent and no approval card is shown — the agent fixes them and drafts a new change (a trialed change cannot be edited). Non-index.* files inside a command directory are helper modules and need not default-export defineCommand.
Trial run: sandbox load (ADR 0040 §3.3, slice C2). When the static checks pass and the change touches a convention directory, the host copies the draft's plugin root into the system temp directory (skipping node_modules, .git, .zhin, data, lib / dist and .env*, never following symlinks; node_modules is linked back to the installed dependencies), applies the draft to the copy, and assembles it with the trial runtime in a disposable child process: only the IM runtime is installed, no configured plugin instances or endpoints are loaded (nothing connects to a real platform), and no model is configured; the child's environment keeps only PATH and locale variables, never tokens; after 60 seconds the whole process group is killed. If assembly throws (a module fails to load, a default export has the wrong shape, …) sandbox_load fails; after a successful assembly every capability file in a top-level convention directory must have been discovered (for example commands/ping.ts is not — it must be commands/ping/index.ts), otherwise capability_discovered fails. If the plugin root does not assemble even without the draft, exceeds the copy limit (5000 files / 100 MB), or the host has no built trial runtime, the stage is recorded as skipped (not a failure; the static checks still gate the change). Changes that only touch the root AGENTS.md or only carry a configPatch are not loaded (a config patch is verified by the post-apply health check). Disk: one trial runs at a time; the zhin-change-trial-* copy is deleted as soon as the trial ends (including failure and timeout), and leftovers older than one hour from a crashed host are removed the next time the trial loader is created. After a passed load the plugin's own tests run (below).
Trial run: plugin self-tests (ADR 0040 §3.2, slice C3). After the sandbox load passes (or when no loader is injected), the host makes the same copy for each plugin root the change touches in a convention directory (draft applied, no node_modules / .env* …), looks for test files (*.test.* / *.spec.*, skipping node_modules, lib / dist and the like), and runs them in an isolated container on the same terms as the P0-c isolated shell: the copy is the only writable mount, the plugin root's nearest node_modules is mounted read-only, --network none, a read-only root filesystem, all capabilities dropped, no-new-privileges, 1 GB memory / 1 CPU / 256 processes, and no Host environment variables (only NODE_ENV=test, CI=1, HOME=/tmp); after 120 seconds the container is force-removed by name. With vitest installed it runs vitest run, otherwise node --test. Failing tests fail plugin_tests and the output tail (at most 1000 characters) goes back to the agent. Tests that fail even without the draft (including host-built native dependencies that cannot run in the container), a plugin without tests, a host without Docker or the image (default node:22-bookworm-slim; the trial never pulls it over the network), or a root over the copy limit are recorded as skipped — draft code never runs tests outside a container. With every trial runner injected trial.pendingStages is empty. Disk: shares the one-trial-at-a-time queue and the zhin-change-trial-* prefix with the sandbox load; the copy is deleted as soon as the tests end (including failure and timeout), and the container runs with --rm.
Approval (ADR 0040 §4, slice D). A change that passes its trial (ready) is submitted for approval at once: ready → pending, plus a card of category self (tool name self_change) in the same approval queue as hard-gate cards. The card carries the change id, content_hash, summary, added / removed lines per file, config paths, impact, the trial verdict with the names of skipped checks (skippedChecks), and a bounded diff excerpt (diffExcerpt, ≤ 40 lines / 2 KB; key = value / key: value assignments with a secret-like key get [redacted] values, and each line is masked again with the audit rules when enqueued; when cut it says the full content is in the Console, and it always carries a "do not approve blind — see the Console" warning, #1142 F3) — never full file bodies or config values. The excerpt is rebuilt from the staged files on submit and when restore() re-issues cards on start. Approvers are the bot's owner (endpoint owner) and toolHardGate.imApproval.approverIds: the card always goes to the owner's private chat — also for changes started in a business group, and also while the IM approval channel is off; with the IM approval channel on, allowlisted approvers also get it through their delivery. The owner settles it in a private chat with the bot using /approve <card id> / /deny <card id> (only self cards — this does not let the owner settle other hard-gate cards); a message in a business group never settles a card. In the Console: plugin.root.self-changes (read, tool_approvals:read or ops:approve; metadata and staging usage usageBytes only), plugin.root.self-change-detail ({ id }, ops:approve only: a unified diff per file — baseline is the rollback snapshot once applying started, otherwise the production file; ≤ 64 KB combined across all files, cut by line with truncated, files past the budget marked budget_exhausted; configPatch values redacted with the decision-audit rules; full trial.checks and skippedChecks; bodiesAvailable: false once slimmed, no invented bodies), plugin.root.resolve-self-change ({ id, decision: 'approve' | 'reject' }, ops:approve) and plugin.root.mark-self-change-handled (a failed change was dealt with, ops:approve); the same card is also visible on plugin.root.tool-approvals (without diffExcerpt, since that list is open to tool_approvals:read), and whichever channel answers first wins. Approval is always a fresh human decision: only approvals through the three human channels (Console, IM private chat, approver group) count — bypass, auto review, remembered grants and the in-turn approval port cannot approve; the approval is bound to the content_hash, and a mismatch ends in rejected (change_hash_mismatch). At most one change per conversation waits for approval: while one is pending, change_draft tells the agent it is waiting and drafts nothing. Approval times out after 24 hours → expired (terminal, staged contents deleted). The approval queue is in memory only; on every start the host re-issues and re-delivers cards for changes still pending, with their remaining time. An approved change stays approved; applying and reporting come in a later slice. Disk: approval stages nothing new; rejected / expired are slimmed at once per §6a.
Apply, verify and roll back (ADR 0040 §5 / §6, slice E1). The apply core SelfChangeApplier takes an approved change (handed over by SelfChangeApprovals through onApproved once the card is settled). It first re-hashes every staged file and re-checks the content_hash the approval is bound to and the configPatch.expectedRevision; any mismatch ends in failed (change_hash_mismatch) without touching any file, reported to the originating conversation only. Then it writes the rollback snapshot snapshot/ (the original target files, files that did not exist recorded as absent, and the root config source when there is a config patch), persists applying, the report target and the apply progress, and only then writes production files (temp file + rename in the same directory, re-checking the path allowlist before every write and never following symlinks) and applies the config patch. By the preview's impact: none goes straight to verification; generation calls the host hot reload (the same entry point as development file watching), upgraded to process when the host says a restart is needed or configuration application confirms pending restart groups; process persists verifying first and then asks for a controlled restart (exit 75). The health check has a 60-second window by default; no answer counts as unhealthy. Healthy → applied, the originating conversation is told "applied" (plus "restarted" when it was), and the staging directory keeps only change.json. Unhealthy → roll back once from the snapshot and recover the same way: healthy again → rolled_back, reported as "failed, rolled back" with the reason; rollback impossible or still unhealthy → failed, the control plane's instance-wide emergency_stop is switched on, the report goes to the owner's private chat only, files/ and snapshot/ are kept as evidence, and nothing restarts again. A change triggers at most two restarts (one forward, one rollback), so it cannot loop. recover() resumes on every start: verifying is health-checked and concluded; applying (the process died mid-apply, files may be half written) rolls back only when configuration ownership is proved and no write/application is unconfirmed; otherwise it retains evidence for manual verification. Report outcomes are audited (change_report_sent / change_report_redirected); a blocked report does not change the change's state. Disk: the snapshot lives in the change's own snapshot/ and is deleted together with files/ on applied / rolled_back; a failed change keeps it until a person marks it handled in the Console.
Host wiring (slice E2). The zhin runtime start process owns the apply host; the newest Agent generation binds its applier and applies are serialized across generations. Hot reload goes through RootHost.reloadSources() — the same HMR path as development file watching (module invalidation + InvalidationPlanner), also with file watching disabled. Configuration already saved and confirmed by Console is not reloaded again: an admitted config-only change proceeds to verification when its receipt confirms all changes applied with no pending restart, refining the conservative restart estimate; mixed changes retain their source impact. Approval and the pre-write gate for instances without a supervisor are unchanged. Legacy hosts without an application receipt retain the config reload path; an explicitly restart-requiring plan retains its existing flow. A restart is the controlled exit 75 (the same exit as the Console "restart"); while a change is applying, a restart requested by file watching is held: the apply flow issues it after persisting verifying, and it is issued when the flow ends if no restart happened. The health check requires a committed generation, every plugin owning a changed file assembled, and no runtime error reported since the change started applying (after a restart: since this process started). Config patches go through Console config:patch one top-level section at a time (host sections use the host allowlist, other sections are scoped to that plugin instance, revisions chain; known validation failures retain the rollback flow. Unconfirmed writes or application outcomes and superseded revisions stop automatic continuation, retain staging and snapshots for manual verification, and do not claim application or rollback success or enable emergency stop merely for uncertainty. Progress durably records the last configuration revision acknowledged to this change. Recovery and rollback use only that revision for CAS; they do not overwrite another edit or guess ownership for older records lacking evidence). Reports: the originating conversation goes through the control plane, and a blocked report is recorded as blocked; when it cannot be delivered (conversation gone, Bot no longer in the group, muted) it goes to that endpoint owner's private chat (change_report_redirected); emergency-stop reports go only to the owner's private chat and are not held by the control plane (they announce the emergency stop just switched on). The emergency stop turns on the instance-wide emergency_stop as self_change:<id>. After every start-up (except --once), recover() runs first, then changes that were approved but never applied (the process exited right after approval) are handled (#1051 Q2=B): those approved within the last hour are applied in order; older ones become expired (no file is touched, audited as change_expired) and the owner is told in a private chat that the approval expired and the change has to be drafted again (the context it was approved in may be stale).
Local plugin roots and no supervisor (#1051 F1). The host takes the local plugin roots inside the project (workspace / local-path plugins) from the committed generation's source ownership — the real path must stay inside the project root and contain no node_modules segment, so a symlink into node_modules or out of the project does not count — and drafting, the trial (grouped by plugin root) and apply share that one list: convention dirs such as plugins/<x>/commands/... can be drafted, plugins under node_modules are still refused, and the list is re-read on every use so a new HMR generation updates it. Automatic restart counts as available only when the zhin start supervisor started the process (ZHIN_SUPERVISOR_PID must be a decimal pid equal to the parent pid, so an inherited image-level value such as ZHIN_SUPERVISOR_PID=1 does not count) and it is not --once; both no supervisor and --once make restart unavailable. Otherwise a change whose impact is process stops at ready after its trial and is not submitted for approval; change_draft tells the model this instance cannot apply it automatically (audited as change_restart_unavailable). An approved change planned as a restart fails without touching any file; one that only turns into a restart while applying (the hot reload asks for a restart, or the config patch needs one) is rolled back from its snapshot without restarting and ends rolled_back; both use the reason code change_restart_unavailable. When the rollback itself needs a restart (restoring the config needs one, the reload after the rollback asks for one, or a rollback resumed by recover() needs one) the host does not request a restart and does not report it as rolled back; it enters emergency stop and tells the owner privately.
Who may draft, and the instance-wide pending cap (#1051 F4). Only the bot's owner (endpoint owner / trigger.masters, turn role master) and IM approvers (approverIds for that platform while toolHardGate.imApproval is on) may draft: in anyone else's turn change_draft is not in the tool list at all (the model cannot try what it cannot see); if it is called anyway it is refused before anything is staged and audited as change_draft_forbidden (deny). Group members, group admins, trusted users and strangers in a private chat cannot draft; who approves is unchanged. At most 3 changes wait for approval across the whole instance: once full, change_draft drafts nothing and tells the model to ask again after the owner settles some (audited as change_pending_cap_reached), so many conversations cannot flood the owner with cards.
How the forbidden config keys are maintained (#1051 F2). SELF_CHANGE_SECURITY_CONFIG_KEYS (listed in the staging-area paragraph above) and the host schema keys tagged x-zhin-config.security are kept in sync by a guard test: any key newly tagged as security-sensitive in the schema is guaranteed to be in the reject set. Rejection happens at draft time and again against the current list right before apply. Bypass, auto-review, the owner role and remembered grants cannot get these keys into an approval through configPatch — a human has to edit the config file themselves. A person editing the file in an editor does not pass through any bot gate; having the bot write the root config with file tools or the shell is still the self hard gate and needs a fresh human approval every time.
Close-out status (#1051, ADR 0040 Accepted). The defaults Fin confirmed are in ADR 0040 "Fin 已确认的默认值". In short: trial checks that were skipped do not block submission, and the card's trial verdict shows how many were skipped and names them ("passed (N skipped); skipped: …"; per-check results are in change.json's trial and the Console detail); one human approval publishes the change — there is no second approval; creating a new local plugin package and installing npm dependencies are out of scope. Regressions are covered by scripted harness evals (packages/im/agent/evals/cases/self-change-*.case.ts: owner DM drafts /ping, re-draft after a failed trial, no fallback to file writes after a forbidden key is refused, no new draft while one is pending, group members cannot draft, no direct writes into convention directories) and by basic/cli/tests/plugin-runtime/self-change-e2e.test.ts (fake supervisor exit 75 → recover() → report, one rollback, emergency stop, process killed mid-apply, the one-hour limit for leftover approved changes), run with pnpm --filter @zhin.js/agent eval and the unit tests. Approval visibility (#1142 F3): the Host self-change-detail RPC and diff excerpts on the IM card are available; see "Approval" above. The Console "self-change" page (using self-changes / self-change-detail / resolve-self-change / mark-self-change-handled) ships with the companion zhinjs/console release. For a user-facing walkthrough see Letting the bot change itself.
ai:
agent:
toolHardGate:
dangerousTools: [my_plugin__wipe_album] # add only; core tools cannot be lowered
allowFunds: false
approvalTimeoutMs: 300000 # denied on timeout
maxPendingPerScene: 3 # pending cards per group; fail closed beyond itApproval cards are served by the Console RPC plugin.root.tool-approvals (read, permission tool_approvals:read or ops:approve) and plugin.root.resolve-tool-approval ({ id, decision: 'approve' | 'deny' }, permission ops:approve or the older tool_approvals:resolve, either one is enough); the full token can call both. Group-ops cooldown (ADR 0037 §3): one approval card per platform + scene_id + tool local name within ops.cooldownMs (default 60 seconds, set in session ops); during the cooldown the call is refused without a new card (ops_cooldown / ops_cooldown_hit). Every decision is written to ai_decision_events: tool_approval (tool_approval_requested / tool_approval_approved / tool_approval_denied / tool_approval_timeout) tool_hard_gate (allow: tool_approval_approved; deny: tool_dangerous_no_approval, tool_approval_denied, tool_approval_timeout, tool_funds_disabled, tool_hard_gate_latched, hard_gate_scene_pending_cap (the group already has its maximum of pending cards)), and tool_call (allow, tool_safe_allowed) for safe / read-only tools, which run but are still audited. An audit write failure never changes the decision.
IM approval channel (#1013, off by default)
On-call approvers can settle hard-gate cards from IM instead of the Console. The channel shares the Console approval queue: whichever answers first wins and the other sees the card as decided. It still only accepts a human, settles one call per answer, and fails closed.
ai:
agent:
toolHardGate:
imApproval:
enabled: true
approverIds: # a flat list applies to every platform; or map platform -> user ids
icqq: ['10001']
delivery: dm # dm (default) messages approvers privately; approver_group posts to approverGroupId
# approverGroupId: '123456' # required for approver_group; must not be a business group- Each new card (a reused same-argument card is not re-sent) goes out through the Endpoint that saw the call, privately to every approver of that platform, with the tool, category, conversation, a redacted argument preview, the card id and its expiry.
- Approvers reply
/approve <card id>or/deny <card id>in the private chat (the prefix follows your command prefix). Only allowlisted users count; messages in groups or channels never settle a card (inapprover_groupmode only the configured approver group does), the conversation that triggered the call can never settle its own card, and saying "approve" in a business group does nothing. - An approval runs exactly one call with the same session and arguments (the existing
consumeApprovedfingerprint); there is no session-wide or permanent grant. The channel is a Host command, not a model-callable tool, so neitherbypassnor the reviewer Agent can reach it. - When a card cannot be delivered (channel off, no approvers for the platform,
approver_groupwithout a group or pointing at the business conversation) or delivery fails, the card stays available in the Console and times out to a denial as usual; delivery is not retried. The queue lives in memory, so cards do not survive a restart. - Audit: settlements in
tool_approvalrecordpayload.channel(console/im_dm/im_approver_group, orturn_promptfor the in-turn prompt) and the operator inactor_id:im:<platform>:<userId>for IM,consolefor the Console for now.payload.imApprovalontool_approval_requestedsays whether the card could be delivered over IM.
Switch inventory and disposition
Dispositions: keep, preset (advanced, use defaults), merge-candidate, debug, internal (code only), deprecated.
| Switch | Disposition | Notes |
|---|---|---|
persona | keep | Common switch, kept |
maxIterations | keep | Common switch, kept |
timeout | keep | Common switch, kept |
chatModel | keep | Common switch, kept |
visionModel | keep | Common switch, kept |
contextTokens | keep | Common switch, kept |
execSecurity | keep | Common switch, kept |
execPreset | keep | Common switch, kept |
network | keep | Dedicated network switch for IM turns (replaces getting egress via execPreset: network), kept |
execAllowlist | keep | Common switch, kept |
execApprovalMode | keep | Common switch, kept |
disabledTools | keep | Common switch, kept |
allowedTools | keep | Common switch, kept |
inboundQueue | keep | Common switch, kept |
reactions | keep | Reaction pacing: per-session cooldownMs 600000, probability 0.35; probability: 0 disables reactions |
toolBudget | keep | Common switch, kept |
injectionGuard | keep | Common switch, kept |
toolHardGate | keep | Dangerous-tool hard gate (ADR 0036); always on, can only be made stricter, see below |
usageQuota | keep | Common switch, kept |
schedule | keep | Common switch, kept |
masterShell | keep | Common switch, kept |
masterShellApprovalMode | keep | For owner DM with masterShell: isolated only; defaults to ask (ask the owner), auto uses review and escalates rejection to the owner, inherit follows execApprovalMode |
compaction | keep | Common switch, kept |
rateLimit | keep | Common switch, kept |
preExecTimeout | preset | Advanced tuning; defaults are recommended |
maxTools | preset | Advanced tuning; defaults are recommended |
contextTailMessageLimit | preset | Advanced tuning; defaults are recommended |
toneAwareness | preset | Advanced tuning; defaults are recommended |
maxHistoryShare | preset | Advanced tuning; defaults are recommended |
maxParallelSubagents | preset | Advanced tuning; defaults are recommended |
toolExecution | preset | Advanced tuning; defaults are recommended |
subagentAutoContinue | preset | Advanced tuning; defaults are recommended |
subagentDirectImDelivery | preset | Advanced tuning; defaults are recommended |
maxSubagentIterations | preset | Advanced tuning; defaults are recommended |
subagentTurnMode | preset | Advanced tuning; defaults are recommended |
subagentTools | preset | Advanced tuning; defaults are recommended |
subagentTurnWaitMs | preset | Advanced tuning; defaults are recommended |
modelSizeHint | preset | Advanced tuning; defaults are recommended |
skillInstructionMaxChars | preset | Advanced tuning; defaults are recommended |
modelHarness | preset | Advanced tuning; defaults are recommended |
providerResilience | preset | Advanced tuning; defaults are recommended |
providerTimers | preset | Advanced tuning; defaults are recommended |
promptCache | preset | Advanced tuning; defaults are recommended |
promptCacheRetention | preset | Advanced tuning; defaults are recommended |
promptCacheKeyPrefix | preset | Advanced tuning; defaults are recommended |
deferredTools | preset | Advanced tuning; defaults are recommended |
workerBaseTools | preset | Advanced tuning; defaults are recommended |
outputSchema | preset | IM defaults to segments; structured replies. Explicit false keeps plain-text output. |
steeringMode | preset | Advanced tuning; defaults are recommended |
followUpMode | preset | Advanced tuning; defaults are recommended |
deferredAutoContinue | preset | Advanced tuning; defaults are recommended |
deferredAutoContinueMaxDepth | preset | Advanced tuning; defaults are recommended |
gitStatus | preset | Advanced tuning; defaults are recommended |
contextPaths | preset | Advanced tuning; defaults are recommended |
systemPromptMaxChars | preset | Advanced tuning; defaults are recommended |
thinkingPreview | preset | Advanced tuning; defaults are recommended |
thinkingPreviewMaxLength | preset | Advanced tuning; defaults are recommended |
subagentExecApprovalMode | merge-candidate | Future merge into per-role overrides of execApprovalMode |
workerExecApprovalMode | merge-candidate | Future merge into per-role overrides of execApprovalMode |
phaseTrace | debug | Diagnostics only |
promptTrace | debug | Diagnostics only |
promptTraceVerbose | debug | Diagnostics only |
recordRequestBodies | keep | Records redacted request bodies locally by default; false disables new body retention while preserving usage statistics |
contextHistory | internal | Code-only field the runtime fills from ai.context.historyTokens / maxRecentMessages (ADR 0043); not YAML-configurable |
onPhaseTrace | internal | Code-only callback, not YAML-configurable |
maxSkills | deprecated | Unused at runtime; deprecated (#735), removed by --fix |
policyDenialStopAfter | deprecated | Unused at runtime; deprecated (#735), removed by --fix |
deferredToolMaxResults | deprecated | Unused at runtime; deprecated (#735), removed by --fix |
taskExecApprovalMode | deprecated | Unused at runtime; deprecated (#735), removed by --fix |
Deprecation path and compatibility
- Deprecated keys are still accepted and never block startup;
- A warning is logged once per deprecated key at startup;
zhin config checkreportsai.agent_<key>_deprecated;zhin config check --fixremoves them;- Types keep them for at least one minor release before removal.
Gate for new switches
Every new ai.agent.* switch must be added to the table above and justified in the PR template. pnpm check:agent-config-inventory (part of pnpm check:all) verifies the inventory is complete and that the non-deprecated switch count does not exceed the baseline.