@nanhara/hara 0.121.1 → 0.122.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +66 -0
- package/README.md +40 -6
- package/dist/agent/loop.js +136 -21
- package/dist/agent/reminders.js +22 -7
- package/dist/agent/repeat-guard.js +26 -7
- package/dist/agent/structured.js +231 -0
- package/dist/agent/touched.js +20 -6
- package/dist/config.js +33 -23
- package/dist/cron/deliver.js +37 -3
- package/dist/feedback.js +3 -2
- package/dist/fs-read.js +106 -12
- package/dist/fs-write.js +242 -16
- package/dist/gateway/dingtalk.js +4 -1
- package/dist/gateway/discord.js +53 -18
- package/dist/gateway/feishu.js +158 -57
- package/dist/gateway/flows-pending.js +720 -0
- package/dist/gateway/flows.js +391 -16
- package/dist/gateway/matrix.js +80 -15
- package/dist/gateway/mattermost.js +44 -32
- package/dist/gateway/media.js +659 -0
- package/dist/gateway/serve.js +657 -162
- package/dist/gateway/sessions.js +475 -78
- package/dist/gateway/signal.js +27 -22
- package/dist/gateway/slack.js +26 -17
- package/dist/gateway/telegram.js +32 -18
- package/dist/gateway/wecom.js +32 -24
- package/dist/gateway/weixin.js +127 -49
- package/dist/hooks.js +32 -20
- package/dist/index.js +631 -222
- package/dist/org/projects.js +347 -0
- package/dist/org/roles.js +38 -11
- package/dist/security/secrets.js +84 -9
- package/dist/serve/server.js +772 -317
- package/dist/serve/sessions.js +113 -33
- package/dist/session/store.js +298 -47
- package/dist/tools/all.js +1 -0
- package/dist/tools/builtin.js +30 -28
- package/dist/tools/codebase.js +3 -1
- package/dist/tools/computer.js +98 -92
- package/dist/tools/edit.js +11 -8
- package/dist/tools/patch.js +230 -31
- package/dist/tools/search.js +482 -72
- package/dist/tools/task.js +453 -0
- package/dist/tools/todo.js +67 -16
- package/dist/tools/web.js +168 -54
- package/dist/undo.js +83 -7
- package/package.json +1 -1
package/CHANGELOG.md
CHANGED
|
@@ -5,6 +5,72 @@ All notable changes to `@nanhara/hara`.
|
|
|
5
5
|
> Versioning (pre-1.0, SemVer-style): the **minor** (middle) number bumps for a **new feature**; the
|
|
6
6
|
> **patch** (last) number bumps for **optimizations/fixes of existing features**.
|
|
7
7
|
|
|
8
|
+
## 0.122.0 — 2026-07-13 — structured runs, durable work, and a fail-closed gateway
|
|
9
|
+
|
|
10
|
+
- **Machine-safe headless runs.** `hara -p … --schema <json|file>` installs a run-scoped
|
|
11
|
+
`structured_output` contract, validates the value against JSON Schema, and writes exactly one JSON value to
|
|
12
|
+
stdout. Prose, auth notices, retries, and provider/schema failures cannot masquerade as machine output:
|
|
13
|
+
diagnostics go to stderr and failures exit non-zero. `hara -p … --role <id>` now applies the role's persona,
|
|
14
|
+
model, and allow/deny tool policy instead of changing the prompt alone.
|
|
15
|
+
- **Agents have stable homes.** `hara projects add/list/remove` maintains a private, atomic registry of project
|
|
16
|
+
homes; `hara agents` builds one address book from global and registered-project roles.
|
|
17
|
+
`hara org --role project:agent …` resolves unambiguously and executes at that home with its project context/config. Concurrent
|
|
18
|
+
CLIs safely share the registry, and dead lock owners can be reclaimed without stealing a live lock.
|
|
19
|
+
- **Two levels of work state.** `todo_write` is now isolated per interactive/headless/sub-agent/serve session
|
|
20
|
+
and persists with the session, so concurrent agents cannot overwrite each other's checklist. The new `task`
|
|
21
|
+
tool is a durable cross-session project pool with owners, statuses, `blockedBy` dependencies, cycle checks,
|
|
22
|
+
and private atomic cross-process persistence. `hara serve` exposes the pool through `tasks.list`.
|
|
23
|
+
- **10-platform gateway, with project-agent roaming.** Telegram, WeChat, Discord, Feishu/Lark, Slack,
|
|
24
|
+
Mattermost, Matrix, DingTalk, WeCom, and Signal adapters now classify direct vs multi-party chats
|
|
25
|
+
conservatively. The full coding agent accepts only verified private messages; unknown channel shapes fail
|
|
26
|
+
closed. `/agent <name|project:name>` pins a thread to an indexed agent and its home, while `/agent main`
|
|
27
|
+
restores the prior project thread. Chat/session preferences survive project round-trips and group-member
|
|
28
|
+
state is isolated.
|
|
29
|
+
- **Untrusted group flows cannot reach coding tools.** Opt-in `~/.hara/flows.json` rules are hot-reloaded and
|
|
30
|
+
evaluated through a bounded, stateless provider call with `tools: []` — no shell, files, MCP, session, or
|
|
31
|
+
project context. Rules support trigger filters, JSON Schema, disposition-based notification/auto-reply,
|
|
32
|
+
redacted rotating logs, and rate/concurrency caps. Proposed sends and agent dispatches are parked unless an
|
|
33
|
+
explicitly configured safe `replyOn` disposition applies.
|
|
34
|
+
- **Single-owner, idempotent approvals.** Consequential flow actions require a unique allowlisted owner and a
|
|
35
|
+
verified private channel. Deterministic `/approve <id>`, `/edit <id> <content>`, and `/reject <id>` commands
|
|
36
|
+
avoid “latest draft” ambiguity; blank edits fail closed. Pending actions use private atomic storage and a
|
|
37
|
+
compare-and-set execution claim, shared by chat and the new serve `approvals.list/resolve` inbox, so two
|
|
38
|
+
approval surfaces cannot double-send. Deferred actions are parked only for one-shot delivery targets
|
|
39
|
+
(Telegram, Feishu/Lark, or WeChat); other adapters fail closed instead of presenting an unusable approval.
|
|
40
|
+
- **Bounded gateway execution and media.** Turns are FIFO per session (depth 8), with four active children,
|
|
41
|
+
bounded global/session-key backlogs, a 15-minute default/30-minute hard timeout, and TERM-to-KILL shutdown;
|
|
42
|
+
non-zero or signalled children fail visibly. Inbound attachments are authorized before download, limited to
|
|
43
|
+
four files and 20 MiB each, streamed into private random paths with time/concurrency/retention quotas, and
|
|
44
|
+
expired after 24 hours. Progress markers are recalled in `finally`, gateway chat/session files are `0600`
|
|
45
|
+
with lock-before-load persistence, and ambiguous/stale locks are never reclaimed merely by age.
|
|
46
|
+
- **Layered flow admission.** Group classifiers are capped globally (20/minute, 120/hour), per rule/platform/chat
|
|
47
|
+
(10/minute, 60/hour), per sender (5/minute), and at four active runs. Rate maps and key sizes are bounded and
|
|
48
|
+
saturation fails closed, so rotating identities cannot grow memory or bypass the host-wide ceiling.
|
|
49
|
+
- **Live config for persistent clients.** `hara serve` rebuilds cwd-specific provider routes, credentials, and
|
|
50
|
+
guardian settings on sessions/turns; `initialize`, `models.list`, and new sessions reflect current defaults.
|
|
51
|
+
Resumed sessions retain their explicit model pin while using the live provider route, so credential rotation
|
|
52
|
+
and project config edits no longer require a server restart.
|
|
53
|
+
- **Coding-path robustness.** File reads/search/edit/patch now reject FIFOs/devices and symlink races, cap reads,
|
|
54
|
+
use bounded subprocess search with a linear glob matcher, preserve exact file modes, and roll back multi-file
|
|
55
|
+
patches without overwriting concurrent replacements; writes move-claim the expected inode and commit with
|
|
56
|
+
create-if-absent semantics, while `/undo` applies the same identity checks. Blank
|
|
57
|
+
project/env routing values no longer mask valid global config; precedence is environment > project > overlay
|
|
58
|
+
> global. Web fetch/search blocks IPv4-mapped and full link-local IPv6 SSRF forms and tries regional providers
|
|
59
|
+
sequentially instead of broadcasting a successful query. Package installs stay attached with bounded
|
|
60
|
+
long-operation timeouts, tunnel commands preflight their binaries, TUI resize repaints reliably, persisted/
|
|
61
|
+
public text redacts secrets, and session writes are private, atomic, and cross-process safe.
|
|
62
|
+
- **Interrupts and non-interactive outcomes are honest.** Cancelling a parallel tool round records matching
|
|
63
|
+
interrupted results while preserving completed side effects; serve approvals settle immediately on interrupt.
|
|
64
|
+
The stream-stall watchdog is a hard Promise boundary even when a provider ignores its abort signal.
|
|
65
|
+
Headless and org runs now exit non-zero on provider errors, empty/halted turns, schema failures, and rejected
|
|
66
|
+
reviews; plan atoms stop on the same outcomes instead of being verified as done, and failed implementers are
|
|
67
|
+
never committed. Read-only roles are resolved before startup and launch neither hooks nor configured/plugin
|
|
68
|
+
MCP subprocesses.
|
|
69
|
+
- **Private serve lifecycle.** `~/.hara`/`serve.json` are tightened to `0700`/`0600` and discovery is fsynced,
|
|
70
|
+
atomically replaced without following symlinks, and removed only by its owning instance. Discovery failures
|
|
71
|
+
close the listening socket. Serve-side compaction is mutually exclusive with turns/config changes, carries an
|
|
72
|
+
abort signal, releases locks on interrupt/shutdown, and has a 60-second hard deadline.
|
|
73
|
+
|
|
8
74
|
## 0.121.1 — field-feedback reliability & credential safety
|
|
9
75
|
|
|
10
76
|
- **Terminal resize no longer erases the composer.** Hara clears stale Ink output only before a real
|
package/README.md
CHANGED
|
@@ -12,7 +12,7 @@
|
|
|
12
12
|
|
|
13
13
|
**Highlights**
|
|
14
14
|
- **An org, not just an agent** — `hara org "<task>"` routes work to the role that *owns* it; `hara plan "<task>"` decomposes a task into a verified DAG of atoms (frame → atomize → sequence → execute → **verify gate**), and `hara plan --parallel` runs independent atoms concurrently.
|
|
15
|
-
- **Drive it from chat** — `hara gateway` runs your local hara from **Telegram · WeChat · Discord · Feishu/Lark · Slack · Mattermost · Matrix · DingTalk · WeCom · Signal** (10 platforms), with **two-way images
|
|
15
|
+
- **Drive it from chat** — `hara gateway` runs your local hara from **Telegram · WeChat · Discord · Feishu/Lark · Slack · Mattermost · Matrix · DingTalk · WeCom · Signal** (10 platforms), with **two-way images**, resumable per-chat sessions, project/agent roaming, bounded per-thread queues, and approval-gated group automations. Connects out — no public webhook. See **[docs/gateway.md](docs/gateway.md)**.
|
|
16
16
|
- **Real terminal UX** — an **ink TUI**: bottom-pinned input box, **plan mode** (read-only investigation → the model submits its plan via `exit_plan` → approve → execute), selectable approvals with "don't ask again", windowed reasoning, **paste images** (Ctrl+V) for vision models, light/dark theme.
|
|
17
17
|
- **Persistent memory + self-evolution** — `memory_*` tools over global/project `MEMORY.md`; the agent recalls before acting, **proactively saves** durable facts, and grows its own playbooks (a lexical guard screens what it writes). Inspect/consolidate it with **`hara memory show`** and **`hara memory distill`** (promote recent daily logs → durable memory). Lexical-first by design — semantic search is opt-in, never required.
|
|
18
18
|
- **Multi-provider, all streamed** — Anthropic (Claude) or any OpenAI-compatible endpoint (Qwen/DashScope, GLM, Kimi, OpenAI) with live Markdown + visible reasoning.
|
|
@@ -135,8 +135,11 @@ hara expresses it the way each endpoint wants (OpenAI `reasoning_effort`, Anthro
|
|
|
135
135
|
DashScope `enable_thinking`, **DeepSeek** V4 `thinking` + `reasoning_effort` where `max` genuinely raises the
|
|
136
136
|
effort). In the TUI, bare `/model` opens a picker — ↑↓ pick a model, **←→ set the thinking level**.
|
|
137
137
|
|
|
138
|
-
Config lives in `~/.hara/config.json`.
|
|
139
|
-
|
|
138
|
+
Config lives in `~/.hara/config.json`; the nearest project `.hara/config.json` can specialize it. Effective
|
|
139
|
+
precedence is **environment > project > selected overlay > global**. Empty routing values are ignored, so an
|
|
140
|
+
empty project/env value cannot hide a working global credential or endpoint. Env overrides include
|
|
141
|
+
`HARA_PROVIDER`, `HARA_MODEL`, `HARA_BASE_URL`, `HARA_API_KEY`, and the provider key
|
|
142
|
+
(`ANTHROPIC_API_KEY` / `DASHSCOPE_API_KEY`).
|
|
140
143
|
|
|
141
144
|
## Use
|
|
142
145
|
|
|
@@ -146,12 +149,17 @@ hara init # analyze the project & (re)generate AGENTS.md
|
|
|
146
149
|
hara doctor # check your setup (auth / model / node / assets / roles)
|
|
147
150
|
hara roles init # scaffold role-agents (implementer / reviewer / docs)
|
|
148
151
|
hara org "review src/ for bugs" # dispatch a task to the role that owns it (or --role <id>)
|
|
152
|
+
hara projects add shop /absolute/path/to/shop # register an agent home
|
|
153
|
+
hara agents # list global + registered project agents
|
|
154
|
+
hara org --role shop:reviewer "audit auth" # run that agent at its own home
|
|
149
155
|
hara plan "add a /health endpoint with a test" # decompose → sequence (DAG) → run each step + verify
|
|
150
156
|
hara plan --parallel "..." # run independent atoms concurrently · hara plan resume # continue a stopped plan
|
|
151
157
|
hara review # review uncommitted changes for bugs/security/missing tests (--staged · --base main)
|
|
152
158
|
hara commit # AI commit message from staged changes, then commit (-a to stage all · -y to skip confirm)
|
|
153
159
|
hara index # build the semantic search index (after: hara config set embedProvider ollama|qwen)
|
|
154
160
|
hara -p "summarize @README.md and fix the lint errors in src/" # one-shot; @path attaches a file
|
|
161
|
+
hara -p "extract package metadata" --schema ./schema.json # stdout is exactly schema-valid JSON
|
|
162
|
+
hara -p "review the current diff" --role reviewer # persona + model + tool policy from the role
|
|
155
163
|
hara --approval auto-edit # suggest (default) | auto-edit | full-auto (-y = full-auto)
|
|
156
164
|
hara --sandbox workspace-write # confine shell writes to the project (macOS Seatbelt)
|
|
157
165
|
hara -c # resume the most recent session in this directory
|
|
@@ -159,6 +167,12 @@ hara --profile work # use a named profile from ~/.hara/config.json
|
|
|
159
167
|
hara -m glm-5 # pick a model
|
|
160
168
|
```
|
|
161
169
|
|
|
170
|
+
For automation, `--schema` accepts inline JSON Schema or a schema file. The model must return through the
|
|
171
|
+
validated `structured_output` tool; on success stdout contains only the JSON value, while diagnostics go to
|
|
172
|
+
stderr and missing/invalid output exits non-zero. `--role reviewer` resolves locally, `--role global:reviewer`
|
|
173
|
+
uses the portable global persona in the current project, and `--role shop:reviewer` runs at that registered
|
|
174
|
+
project home. Each form enforces the role's persona, model, `allowTools`/`denyTools`, and `readOnly` policy.
|
|
175
|
+
|
|
162
176
|
Inside the REPL: `/help` `/init` `/tools` `/model` `/approval` `/org` `/plan` `/roles` `/usage` `/doctor` `/sessions` `/undo` `/compact` `/recall` `/reset` `/exit` (type `/`+Tab to complete). Type `@` + Tab to attach a file (fuzzy, walks subdirectories).
|
|
163
177
|
|
|
164
178
|
The interactive REPL is an **ink TUI**: a bordered **input box pinned at the bottom** — session name in
|
|
@@ -216,9 +230,22 @@ vision model into **actionable** output — interactive elements + positions (pa
|
|
|
216
230
|
**MCP**: add an `mcpServers` map to config (global or project `.hara/config.json`); their tools appear to the agent as `mcp__<server>__<tool>`. hara can also **be** an MCP server — `hara mcp` exposes its read/search tools (esp. **`codebase_search`**) over stdio so other clients (Claude Desktop, Cursor, another hara) can use them; read-only by default (`HARA_MCP_TOOLS` to override).
|
|
217
231
|
**Vim mode**: `hara config set vimMode true` makes the prompt modal — Esc → normal, `i/a/A/I` insert, `h l 0 $ w b e` motions, `x D C dd cw p` edits. Off by default.
|
|
218
232
|
**Scheduled tasks**: `hara cron add "0 9 * * 1-5" "<task>"` (or `"every 30m"`, `"in 2h"`) runs a task on a schedule — each run is a fresh hara session. `hara cron install` wires a per-minute tick into launchd/crontab (no daemon); `--org` routes through the role org. Manage with `hara cron list/run/enable/disable/remove/logs`.
|
|
233
|
+
**Work coordination**: `todo_write` is the agent's short, session-scoped checklist; it persists with that
|
|
234
|
+
session and is isolated between simultaneous sub-agents and serve sessions. `task` is the durable project pool
|
|
235
|
+
for work that outlives a conversation: add/update/list/remove items with `pending|in_progress|done`, an optional
|
|
236
|
+
owner, and `blockedBy` dependencies. The private, atomic store is shared by concurrent hara processes for the
|
|
237
|
+
same project and rejects missing/self/cyclic dependencies.
|
|
219
238
|
**Notifications**: `hara config set notify bell` (terminal bell) or `notify system` (OS notification) pings you when a turn finishes — handy for long runs you've stepped away from. Gated on elapsed time so quick turns stay quiet; off by default.
|
|
220
239
|
**Hooks**: run your own shell commands around tool calls via a `"hooks"` map in config. A **`PreToolUse`** hook can **veto** a call (non-zero exit blocks it; its output becomes the reason the model sees) — gate `bash`, forbid edits outside a path, require a clean tree. A **`PostToolUse`** hook observes (format/lint a file the agent just wrote, log, notify). Each has a `matcher` (regex/literal on the tool name, `*` = all) and gets `{tool, payload}` on stdin + `HARA_TOOL_NAME` in env. Plugins can contribute hooks too.
|
|
221
|
-
|
|
240
|
+
Reviewer/read-only/plan runs and parallel read-only sub-agents suppress both hook phases: PreToolUse and
|
|
241
|
+
PostToolUse commands are arbitrary shell, so either could otherwise bypass their read-only contract indirectly.
|
|
242
|
+
They also skip configured and plugin-provided MCP server processes, so starting an external tool server cannot
|
|
243
|
+
bypass the same contract before the first model turn.
|
|
244
|
+
**Profiles and live config**: select an identity with `--profile <name>`; use `overlays` in
|
|
245
|
+
`~/.hara/config.json` for named config overlays, and a project `.hara/config.json` for project-specific routing.
|
|
246
|
+
Long-lived `hara serve` processes reload provider credentials/routes and guardian settings for the target cwd on
|
|
247
|
+
new sessions and turns. `models.list` and new sessions see current defaults; a resumed session keeps its explicit
|
|
248
|
+
model pin while using the live provider route. No server restart is required after a credential rotation.
|
|
222
249
|
|
|
223
250
|
### The org — what makes hara different
|
|
224
251
|
|
|
@@ -234,6 +261,13 @@ to a clean start tree; a review that doesn't pass leaves the work uncommitted).
|
|
|
234
261
|
**`agent`** tool spawns **parallel read-only sub-agents** for fan-out — analyze / review / search
|
|
235
262
|
several things at once (each can take a `role`), bounded to 8 concurrent (`HARA_MAX_CONCURRENCY`).
|
|
236
263
|
|
|
264
|
+
Register project homes with `hara projects add <name> <absolute-path>`, then `hara agents` becomes a global
|
|
265
|
+
address book across `~/.hara/roles` and each registered project's roles. A qualified address such as
|
|
266
|
+
`shop:reviewer` is unambiguous; both `hara org --role shop:reviewer "<task>"` and one-shot `hara -p "<task>"
|
|
267
|
+
--role shop:reviewer` execute at that agent's home, with its own `AGENTS.md`, live project config, role model,
|
|
268
|
+
and allow/deny/read-only tool policy. `global:<name>` is portable and runs in the current project. A bare name
|
|
269
|
+
uses the local role first and otherwise must resolve unambiguously.
|
|
270
|
+
|
|
237
271
|
Beyond routing, **`hara plan "<task>"`** makes the org *plan*: it decomposes the task into atoms,
|
|
238
272
|
sequences them as a DAG, and executes each step (optionally routed to a role) behind a per-step
|
|
239
273
|
**verify gate** — frame → atomize → sequence → execute → verify. Each atom may carry a `check` shell
|
|
@@ -255,11 +289,11 @@ turn, or **`/undo`** to revert the last edit. In-session **`/diff`**, **`/review
|
|
|
255
289
|
- **Project context**: auto-loads `AGENTS.md` (the cross-tool standard) walking up to the repo root; `hara init` writes one by analyzing the repo.
|
|
256
290
|
- **`@file` mentions**: attach file contents to a message (`@path`); Tab-completes with a **fuzzy** matcher over the project (subdirs, git-tracked + untracked) — `@idx` → `src/index.ts`. `@<dir>` loads a directory listing, `@src/`+Tab drills into a folder, and mistyped tool/file paths get a "did you mean" suggestion.
|
|
257
291
|
- **Multi-provider**: Anthropic (Claude) or any OpenAI-compatible endpoint (Qwen/DashScope, GLM, Kimi, OpenAI) — **all streamed live**.
|
|
258
|
-
- **Chat gateway**: drive your local hara from
|
|
292
|
+
- **Chat gateway**: drive your local hara from **Telegram · WeChat · Discord · Feishu/Lark · Slack · Mattermost · Matrix · DingTalk · WeCom · Signal**. The daemon connects out (no public webhook), with per-chat sessions, project roaming (`/cd`), agent switching (`/agent`), and **two-way images** (send a photo → it sees it; ask for a file → it sends one). Setup and the group-flow security model: **[docs/gateway.md](docs/gateway.md)**.
|
|
259
293
|
|
|
260
294
|
### Roadmap
|
|
261
295
|
|
|
262
|
-
**Shipped:** ink TUI · plan mode · persistent memory + self-evolution · atomization planner · parallel plan atoms · **multi-role review chains** · parallel sub-agents · MCP client *and* server · **scheduled tasks (`hara cron`)** · **chat gateway (
|
|
296
|
+
**Shipped:** ink TUI · plan mode · persistent memory + self-evolution · atomization planner · parallel plan atoms · **multi-role review chains** · global project-agent index · durable project tasks · parallel sub-agents · MCP client *and* server · **scheduled tasks (`hara cron`)** · **chat gateway (10 platforms, two-way images)** · **single-binary distribution** · **Docker image** · `/compact` context management.
|
|
263
297
|
**Next:** SSOT data authority · an enterprise control-plane (fleet + central token management).
|
|
264
298
|
|
|
265
299
|
## Security
|
package/dist/agent/loop.js
CHANGED
|
@@ -96,8 +96,8 @@ site, build output), check they are newer than their sources (compare mtimes or
|
|
|
96
96
|
the sources changed since the artifacts were built, run the project's documented build/render steps FIRST.
|
|
97
97
|
When AGENTS.md / README / package.json document a command sequence (e.g. pull → render → build → preview),
|
|
98
98
|
that ordering is authoritative — never skip the middle steps, or you serve stale output and the user sees
|
|
99
|
-
two-day-old work. Package-manager installs
|
|
100
|
-
|
|
99
|
+
two-day-old work. Package-manager installs receive a longer attached timeout by default; use background jobs
|
|
100
|
+
only when explicitly appropriate, and poll a background job before depending on it. Before opening a public tunnel,
|
|
101
101
|
verify that provider's authentication/config once; if it is missing, stop and ask instead of trying a chain
|
|
102
102
|
of unrelated tunnel tools. After completing a task, give a one-line summary.`;
|
|
103
103
|
/** When running inside `hara gateway`, tell the agent it's in a chat — so it delivers files via send_file
|
|
@@ -111,7 +111,9 @@ function gatewayNote() {
|
|
|
111
111
|
`To send a file or image to them, call the \`send_file\` tool with an absolute path; that is the ONLY channel ` +
|
|
112
112
|
`that reaches this chat. Do NOT use the \`computer\` tool, AppleScript, or any desktop/${plat}-client automation ` +
|
|
113
113
|
`to deliver files — that drives a different window and silently fails to reach the user. Never tell the user a ` +
|
|
114
|
-
`file was sent unless \`send_file\` returned success. Keep replies short and chat-friendly
|
|
114
|
+
`file was sent unless \`send_file\` returned success. Keep replies short and chat-friendly. ` +
|
|
115
|
+
`PLAIN TEXT ONLY: chat bubbles render markdown literally — never use **bold**, # headers, backticks, tables, ` +
|
|
116
|
+
`or [text](url); write list items as "- " lines and links as bare URLs.`);
|
|
115
117
|
}
|
|
116
118
|
function composeSystem(cwd, projectContext, override, memory) {
|
|
117
119
|
const head = override ? `${override}\n\nWorking directory: ${cwd}` : HARA_SYSTEM(cwd);
|
|
@@ -129,6 +131,16 @@ export async function runAgent(history, opts) {
|
|
|
129
131
|
let activeProvider = provider; // may switch to a fallback model on a recoverable error (app-failover)
|
|
130
132
|
let triedFallback = false;
|
|
131
133
|
let emptyRetried = false; // one-shot: a genuinely empty model turn gets a single nudge before we give up
|
|
134
|
+
const interruptedOutcome = () => {
|
|
135
|
+
const msg = "(interrupted)";
|
|
136
|
+
if (!opts.quiet) {
|
|
137
|
+
if (ctx.ui)
|
|
138
|
+
ctx.ui.notice(msg);
|
|
139
|
+
else
|
|
140
|
+
out(c.dim(`\n${msg}\n`));
|
|
141
|
+
}
|
|
142
|
+
return { status: "error", error: msg };
|
|
143
|
+
};
|
|
132
144
|
// Warn at the interaction boundary without echoing the value. Headless/gateway stdout is the response
|
|
133
145
|
// transport, so keep the banner to interactive surfaces; persistence is still redacted everywhere.
|
|
134
146
|
const latestUser = [...history].reverse().find((m) => m.role === "user");
|
|
@@ -166,7 +178,7 @@ export async function runAgent(history, opts) {
|
|
|
166
178
|
// lands as ONE wrapped user message the UI never renders. Quiet runs don't drain — a parallel
|
|
167
179
|
// sub-agent must not steal the main conversation's reminders.
|
|
168
180
|
if (!opts.quiet) {
|
|
169
|
-
const reminders = drainReminders();
|
|
181
|
+
const reminders = drainReminders(ctx.todoScope);
|
|
170
182
|
if (reminders.length)
|
|
171
183
|
history.push({ role: "user", content: wrapReminders(reminders) });
|
|
172
184
|
}
|
|
@@ -212,7 +224,12 @@ export async function runAgent(history, opts) {
|
|
|
212
224
|
const STALL_MS = stallMs();
|
|
213
225
|
const attempt = new AbortController();
|
|
214
226
|
const onUserAbort = () => attempt.abort();
|
|
215
|
-
|
|
227
|
+
// AbortSignal does not replay an already-fired event to a late listener. A serve shutdown can cancel
|
|
228
|
+
// while provider routing is still refreshing, so inherit that state synchronously before the call.
|
|
229
|
+
if (opts.signal?.aborted)
|
|
230
|
+
attempt.abort();
|
|
231
|
+
else
|
|
232
|
+
opts.signal?.addEventListener("abort", onUserAbort, { once: true });
|
|
216
233
|
let lastEvent = Date.now();
|
|
217
234
|
let stalled = false;
|
|
218
235
|
const stallTimer = setInterval(() => {
|
|
@@ -229,17 +246,33 @@ export async function runAgent(history, opts) {
|
|
|
229
246
|
if (!opts.quiet)
|
|
230
247
|
setTurnPhase("waiting"); // request sent, nothing streamed yet — the status row shows it
|
|
231
248
|
let r;
|
|
249
|
+
let removeAttemptStop = () => { };
|
|
232
250
|
try {
|
|
233
|
-
|
|
251
|
+
// AbortSignal is advisory: a custom/provider SDK can ignore it and leave its Promise pending forever.
|
|
252
|
+
// Race the attempt itself so the watchdog and user cancellation remain hard boundaries. The abandoned
|
|
253
|
+
// Promise retains a rejection handler through Promise.race, and all late stream callbacks below are muted.
|
|
254
|
+
const attemptStopped = new Promise((resolveStopped) => {
|
|
255
|
+
const onAttemptStop = () => resolveStopped({ text: "", toolUses: [], stop: "error", errorMsg: "interrupted" });
|
|
256
|
+
removeAttemptStop = () => attempt.signal.removeEventListener("abort", onAttemptStop);
|
|
257
|
+
if (attempt.signal.aborted)
|
|
258
|
+
onAttemptStop();
|
|
259
|
+
else
|
|
260
|
+
attempt.signal.addEventListener("abort", onAttemptStop, { once: true });
|
|
261
|
+
});
|
|
262
|
+
const providerTurn = activeProvider.turn({
|
|
234
263
|
system: composeSystem(ctx.cwd, opts.projectContext, opts.systemOverride, opts.memory),
|
|
235
264
|
history,
|
|
236
265
|
tools: specs,
|
|
237
266
|
// Any stream chunk keeps the connection considered alive — even suppressed reasoning_content, so a
|
|
238
267
|
// reasoning model thinking for a long while before its first `content` token can't be false-timed-out.
|
|
239
268
|
onActivity: () => {
|
|
269
|
+
if (attempt.signal.aborted)
|
|
270
|
+
return;
|
|
240
271
|
lastEvent = Date.now();
|
|
241
272
|
},
|
|
242
273
|
onText: (d) => {
|
|
274
|
+
if (attempt.signal.aborted)
|
|
275
|
+
return;
|
|
243
276
|
alive();
|
|
244
277
|
if (opts.quiet)
|
|
245
278
|
return;
|
|
@@ -256,6 +289,8 @@ export async function runAgent(history, opts) {
|
|
|
256
289
|
},
|
|
257
290
|
onReasoning: sink || tty
|
|
258
291
|
? (d) => {
|
|
292
|
+
if (attempt.signal.aborted)
|
|
293
|
+
return;
|
|
259
294
|
alive();
|
|
260
295
|
if (opts.quiet)
|
|
261
296
|
return;
|
|
@@ -281,12 +316,23 @@ export async function runAgent(history, opts) {
|
|
|
281
316
|
}
|
|
282
317
|
}
|
|
283
318
|
}
|
|
284
|
-
: (
|
|
319
|
+
: () => {
|
|
320
|
+
if (!attempt.signal.aborted)
|
|
321
|
+
alive();
|
|
322
|
+
}, // quiet runs still feed the watchdog (reasoning-only stretches are progress)
|
|
285
323
|
signal: attempt.signal,
|
|
286
324
|
});
|
|
325
|
+
opts.onProviderTurn?.(providerTurn);
|
|
326
|
+
r = await Promise.race([providerTurn, attemptStopped]);
|
|
327
|
+
}
|
|
328
|
+
catch (error) {
|
|
329
|
+
if (!opts.signal?.aborted)
|
|
330
|
+
throw error;
|
|
331
|
+
r = { text: "", toolUses: [], stop: "error", errorMsg: "interrupted" };
|
|
287
332
|
}
|
|
288
333
|
finally {
|
|
289
334
|
clearInterval(stallTimer);
|
|
335
|
+
removeAttemptStop();
|
|
290
336
|
opts.signal?.removeEventListener("abort", onUserAbort);
|
|
291
337
|
}
|
|
292
338
|
// A watchdog abort surfaces from the provider as "interrupted" — rewrite it to a timeout-class
|
|
@@ -304,6 +350,10 @@ export async function runAgent(history, opts) {
|
|
|
304
350
|
opts.stats.output += r.usage.output;
|
|
305
351
|
opts.stats.lastInput = r.usage.input;
|
|
306
352
|
}
|
|
353
|
+
// A provider may ignore AbortSignal and return a perfectly valid-looking tool_use after cancellation.
|
|
354
|
+
// The original run signal is authoritative: do not append/approve/execute any late response.
|
|
355
|
+
if (opts.signal?.aborted)
|
|
356
|
+
return interruptedOutcome();
|
|
307
357
|
history.push({ role: "assistant", text: r.text, toolUses: r.toolUses });
|
|
308
358
|
if (r.stop === "error") {
|
|
309
359
|
const kind = classifyError(r.errorMsg ?? "");
|
|
@@ -321,13 +371,26 @@ export async function runAgent(history, opts) {
|
|
|
321
371
|
continue; // retry once on the fallback model (guarded by triedFallback)
|
|
322
372
|
}
|
|
323
373
|
const msg = kind === "interrupted" ? "(interrupted)" : `[${activeProvider.id} error] ${r.errorMsg ?? "unknown"}${errorHint(kind)}`;
|
|
374
|
+
if (r.toolUses.length) {
|
|
375
|
+
// A provider can fail after partially assembling tool calls. The assistant turn is already persisted;
|
|
376
|
+
// close every call explicitly so the next request is valid, while never executing partial work.
|
|
377
|
+
history.push({
|
|
378
|
+
role: "tool",
|
|
379
|
+
results: r.toolUses.map((toolUse) => ({
|
|
380
|
+
id: toolUse.id,
|
|
381
|
+
name: toolUse.name,
|
|
382
|
+
content: `Error: provider failed before this tool call could be executed. ${r.errorMsg ?? "unknown provider error"}`,
|
|
383
|
+
isError: true,
|
|
384
|
+
})),
|
|
385
|
+
});
|
|
386
|
+
}
|
|
324
387
|
if (!opts.quiet) {
|
|
325
388
|
if (sink)
|
|
326
389
|
sink.notice(msg);
|
|
327
390
|
else
|
|
328
391
|
out(kind === "interrupted" ? c.dim(`\n${msg}\n`) : c.red(`${msg}\n`));
|
|
329
392
|
}
|
|
330
|
-
return;
|
|
393
|
+
return { status: "error", error: msg };
|
|
331
394
|
}
|
|
332
395
|
// Empty-turn guard. The model returned nothing actionable — no text AND no tool calls (a blank
|
|
333
396
|
// completion, or a "tool_use" stop with an empty tool list). Silently returning here leaves the
|
|
@@ -355,16 +418,37 @@ export async function runAgent(history, opts) {
|
|
|
355
418
|
else
|
|
356
419
|
out(c.dim(`${note}\n`));
|
|
357
420
|
}
|
|
358
|
-
return;
|
|
421
|
+
return { status: "empty" };
|
|
359
422
|
}
|
|
360
423
|
// A "tool_use" stop with text but no tools (rare) has nothing to execute — end after showing the text
|
|
361
424
|
// rather than pushing an empty tool round and re-requesting in a loop.
|
|
362
425
|
if (r.stop !== "tool_use" || r.toolUses.length === 0)
|
|
363
|
-
return;
|
|
426
|
+
return { status: "completed" };
|
|
427
|
+
// Once an assistant tool_use turn enters history, every tool_use MUST receive a matching tool result.
|
|
428
|
+
// OpenAI/Anthropic both reject a later user turn after an unclosed tool round. Cancellation can happen
|
|
429
|
+
// while planning, approving, or executing, so finalize the round with real results for work that already
|
|
430
|
+
// completed and explicit interruption errors for everything else before persisting the session.
|
|
431
|
+
const results = new Array(r.toolUses.length);
|
|
432
|
+
const finalizeInterruptedToolRound = () => {
|
|
433
|
+
history.push({
|
|
434
|
+
role: "tool",
|
|
435
|
+
results: r.toolUses.map((tu, idx) => results[idx] ?? ({
|
|
436
|
+
id: tu.id,
|
|
437
|
+
name: tu.name,
|
|
438
|
+
content: "Error: interrupted before this tool call completed.",
|
|
439
|
+
isError: true,
|
|
440
|
+
})),
|
|
441
|
+
});
|
|
442
|
+
return interruptedOutcome();
|
|
443
|
+
};
|
|
444
|
+
if (opts.signal?.aborted)
|
|
445
|
+
return finalizeInterruptedToolRound();
|
|
364
446
|
const plans = [];
|
|
365
447
|
// Extra (per-run) tools win over the registry so a run-scoped tool can't be shadowed by a global one.
|
|
366
448
|
const resolveTool = (name) => opts.extraTools?.find((t) => t.name === name) ?? getTool(name);
|
|
367
449
|
for (const tu of r.toolUses) {
|
|
450
|
+
if (opts.signal?.aborted)
|
|
451
|
+
return finalizeInterruptedToolRound();
|
|
368
452
|
if (breakerHalt) {
|
|
369
453
|
// Circuit-breaker halted the run: refuse every remaining call in this round with a clear message
|
|
370
454
|
// (no hang, no further tools) so the model + user get a definitive stop.
|
|
@@ -398,6 +482,8 @@ export async function runAgent(history, opts) {
|
|
|
398
482
|
if (risk.level === "high") {
|
|
399
483
|
const detail = String(input.command ?? input.path ?? "").replace(/\s+/g, " ").trim().slice(0, 400);
|
|
400
484
|
const verdict = await guardianVeto(opts.guardian.provider, { tool: tu.name, detail, classifierReason: risk.reason }, history, { signal: opts.signal });
|
|
485
|
+
if (opts.signal?.aborted)
|
|
486
|
+
return finalizeInterruptedToolRound();
|
|
401
487
|
if (verdict.decision === "block") {
|
|
402
488
|
const tripped = recordBlock(breaker); // deterministic circuit-breaker: N blocks → hard stop
|
|
403
489
|
plans.push({
|
|
@@ -421,6 +507,8 @@ export async function runAgent(history, opts) {
|
|
|
421
507
|
const cont = interactive
|
|
422
508
|
? await opts.confirm(`${c.red("⛔ guardian circuit-breaker")} — ${breaker.blocks} high-risk actions blocked this turn. Continue anyway?`)
|
|
423
509
|
: false;
|
|
510
|
+
if (opts.signal?.aborted)
|
|
511
|
+
return finalizeInterruptedToolRound();
|
|
424
512
|
if (cont === false) {
|
|
425
513
|
breakerHalt = true;
|
|
426
514
|
}
|
|
@@ -435,6 +523,8 @@ export async function runAgent(history, opts) {
|
|
|
435
523
|
}
|
|
436
524
|
if (cmdDecision !== "allow" && needsConfirm(tool.kind, opts.approval) && (alwaysGate || !opts.autoApprove?.has(tu.name))) {
|
|
437
525
|
const reply = await opts.confirm(`${c.yellow("⚠")} ${c.bold(tu.name)} ${c.dim(preview)} — run?`);
|
|
526
|
+
if (opts.signal?.aborted)
|
|
527
|
+
return finalizeInterruptedToolRound();
|
|
438
528
|
if (reply === false) {
|
|
439
529
|
plans.push({ tu, tool, denied: "User denied this action." });
|
|
440
530
|
continue;
|
|
@@ -452,8 +542,11 @@ export async function runAgent(history, opts) {
|
|
|
452
542
|
}
|
|
453
543
|
}
|
|
454
544
|
// Execute: read-only tools run concurrently; edit/exec run alone, in order.
|
|
455
|
-
|
|
545
|
+
if (opts.signal?.aborted)
|
|
546
|
+
return finalizeInterruptedToolRound();
|
|
456
547
|
const runOne = async (idx, p) => {
|
|
548
|
+
if (opts.signal?.aborted)
|
|
549
|
+
return;
|
|
457
550
|
if (p.denied !== undefined) {
|
|
458
551
|
results[idx] = { id: p.tu.id, name: p.tu.name, content: p.denied, isError: true };
|
|
459
552
|
return;
|
|
@@ -467,28 +560,42 @@ export async function runAgent(history, opts) {
|
|
|
467
560
|
if (missing.length) {
|
|
468
561
|
const msg = `Error: tool call NOT executed — missing required parameter${missing.length > 1 ? "s" : ""}: ` +
|
|
469
562
|
`${missing.join(", ")}. Send the call again with ALL required parameters (${(p.tool.input_schema.required ?? []).join(", ")}) present and complete.`;
|
|
470
|
-
results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + recordCall(p.tu.name, p.tu.input, msg, true), isError: true };
|
|
563
|
+
results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + recordCall(p.tu.name, p.tu.input, msg, true, ctx.todoScope), isError: true };
|
|
471
564
|
return;
|
|
472
565
|
}
|
|
473
|
-
|
|
566
|
+
if (opts.signal?.aborted)
|
|
567
|
+
return;
|
|
568
|
+
const pre = opts.hooks === false
|
|
569
|
+
? { block: false, message: "" }
|
|
570
|
+
: runHooks("PreToolUse", p.tu.name, p.tu.input, ctx.cwd); // a hook may veto the call
|
|
474
571
|
if (pre.block) {
|
|
475
572
|
results[idx] = { id: p.tu.id, name: p.tu.name, content: pre.message, isError: true };
|
|
476
573
|
return;
|
|
477
574
|
}
|
|
575
|
+
if (opts.signal?.aborted)
|
|
576
|
+
return;
|
|
478
577
|
// Track the MAIN conversation's working files for post-compaction restore (quiet fan-out
|
|
479
578
|
// sub-agents read broadly — their files aren't "what the user was working on").
|
|
480
579
|
if (!opts.quiet && FILE_TOUCH_TOOLS.has(p.tu.name) && typeof p.tu.input?.path === "string") {
|
|
481
|
-
recordTouch(resolvePath(ctx.cwd, String(p.tu.input.path)));
|
|
580
|
+
recordTouch(resolvePath(ctx.cwd, String(p.tu.input.path)), ctx.todoScope);
|
|
482
581
|
}
|
|
483
582
|
const res = await p.tool.run(p.tu.input, ctx);
|
|
484
583
|
// append any not-yet-seen subdirectory AGENTS.md/CLAUDE.md this call touched (monorepo-local conventions)
|
|
485
584
|
// + the repeat-guard's anti-spinning note when this exact call keeps failing (repeat-guard.ts)
|
|
486
|
-
results[idx] = { id: p.tu.id, name: p.tu.name, content: res + subdirHint(p.tu.input, ctx.cwd) + recordCall(p.tu.name, p.tu.input, res) };
|
|
487
|
-
|
|
585
|
+
results[idx] = { id: p.tu.id, name: p.tu.name, content: res + subdirHint(p.tu.input, ctx.cwd) + recordCall(p.tu.name, p.tu.input, res, false, ctx.todoScope) };
|
|
586
|
+
// The tool may have completed a side effect and then triggered/observed cancellation. Preserve its
|
|
587
|
+
// actual result in the closing tool round, but do not run any post hook or later tool afterward.
|
|
588
|
+
if (opts.signal?.aborted)
|
|
589
|
+
return;
|
|
590
|
+
if (opts.hooks !== false) {
|
|
591
|
+
runHooks("PostToolUse", p.tu.name, { input: p.tu.input, result: res }, ctx.cwd); // observe-only
|
|
592
|
+
}
|
|
488
593
|
}
|
|
489
594
|
catch (e) {
|
|
490
595
|
const msg = `Error: ${e.message}`;
|
|
491
|
-
results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + recordCall(p.tu.name, p.tu.input, msg, true), isError: true };
|
|
596
|
+
results[idx] = { id: p.tu.id, name: p.tu.name, content: msg + recordCall(p.tu.name, p.tu.input, msg, true, ctx.todoScope), isError: true };
|
|
597
|
+
if (opts.signal?.aborted)
|
|
598
|
+
return;
|
|
492
599
|
}
|
|
493
600
|
finally {
|
|
494
601
|
activity.dec();
|
|
@@ -503,16 +610,24 @@ export async function runAgent(history, opts) {
|
|
|
503
610
|
await mapLimit(idx, maxParallel(), (i) => runOne(i, plans[i])); // bounded fan-out (e.g. 20 parallel agents → 8 at a time)
|
|
504
611
|
};
|
|
505
612
|
for (let i = 0; i < plans.length; i++) {
|
|
613
|
+
if (opts.signal?.aborted)
|
|
614
|
+
return finalizeInterruptedToolRound();
|
|
506
615
|
const p = plans[i];
|
|
507
616
|
if (p.denied === undefined && p.tool?.kind === "read") {
|
|
508
617
|
batch.push(i); // safe → accumulate to run concurrently
|
|
509
618
|
}
|
|
510
619
|
else {
|
|
511
620
|
await flush(); // flush pending reads before an edit/exec
|
|
621
|
+
if (opts.signal?.aborted)
|
|
622
|
+
return finalizeInterruptedToolRound();
|
|
512
623
|
await runOne(i, p);
|
|
624
|
+
if (opts.signal?.aborted)
|
|
625
|
+
return finalizeInterruptedToolRound();
|
|
513
626
|
}
|
|
514
627
|
}
|
|
515
628
|
await flush();
|
|
629
|
+
if (opts.signal?.aborted)
|
|
630
|
+
return finalizeInterruptedToolRound();
|
|
516
631
|
history.push({ role: "tool", results });
|
|
517
632
|
// Synthesis nudge (CC's KN5, hara-shaped): a round that fanned out to several parallel agents just
|
|
518
633
|
// produced N independent reports — remind the model to merge/reconcile them before acting, instead
|
|
@@ -520,7 +635,7 @@ export async function runAgent(history, opts) {
|
|
|
520
635
|
if (!opts.quiet) {
|
|
521
636
|
const fanout = r.toolUses.filter((tu) => tu.name === "agent").length;
|
|
522
637
|
if (fanout >= SYNTHESIS_MIN_AGENTS)
|
|
523
|
-
pushReminder(synthesisReminder(fanout));
|
|
638
|
+
pushReminder(synthesisReminder(fanout), ctx.todoScope);
|
|
524
639
|
}
|
|
525
640
|
// Todo attention-refresh: a round that touched the checklist resets the clock; rounds that leave
|
|
526
641
|
// unfinished items untouched accumulate, and at TODO_STALE_ROUNDS the model gets a system-reminder
|
|
@@ -529,10 +644,10 @@ export async function runAgent(history, opts) {
|
|
|
529
644
|
if (r.toolUses.some((tu) => tu.name === "todo_write")) {
|
|
530
645
|
todoIdleRounds = 0;
|
|
531
646
|
}
|
|
532
|
-
else if (currentTodos().some((t) => t.status !== "done")) {
|
|
647
|
+
else if (currentTodos(ctx.todoScope).some((t) => t.status !== "done")) {
|
|
533
648
|
todoIdleRounds++;
|
|
534
649
|
if (todoIdleRounds >= TODO_STALE_ROUNDS) {
|
|
535
|
-
pushReminder(todoStaleReminder(renderTodos(currentTodos())));
|
|
650
|
+
pushReminder(todoStaleReminder(renderTodos(currentTodos(ctx.todoScope))), ctx.todoScope);
|
|
536
651
|
todoIdleRounds = 0;
|
|
537
652
|
}
|
|
538
653
|
}
|
|
@@ -547,7 +662,7 @@ export async function runAgent(history, opts) {
|
|
|
547
662
|
else
|
|
548
663
|
out(c.red(`${note}\n`));
|
|
549
664
|
}
|
|
550
|
-
return;
|
|
665
|
+
return { status: "halted" };
|
|
551
666
|
}
|
|
552
667
|
if (guard && !nudged) {
|
|
553
668
|
for (const p of plans)
|
package/dist/agent/reminders.js
CHANGED
|
@@ -6,19 +6,34 @@
|
|
|
6
6
|
//
|
|
7
7
|
// Claude Code's disclaimer is preserved: the model is told the context may be irrelevant, so an
|
|
8
8
|
// injected nudge never derails an unrelated task.
|
|
9
|
-
const
|
|
9
|
+
const DEFAULT_SCOPE = "default";
|
|
10
|
+
const queues = new Map();
|
|
11
|
+
function scopeKey(scope) {
|
|
12
|
+
return scope?.trim() || DEFAULT_SCOPE;
|
|
13
|
+
}
|
|
10
14
|
/** Queue a reminder for injection before the next model call (main loop only — quiet/sub-agent runs
|
|
11
15
|
* neither push nor drain, so a parallel fan-out can't steal the main conversation's reminders). */
|
|
12
|
-
export function pushReminder(text) {
|
|
16
|
+
export function pushReminder(text, scope) {
|
|
13
17
|
const t = text.trim();
|
|
14
|
-
if (t)
|
|
15
|
-
|
|
18
|
+
if (!t)
|
|
19
|
+
return;
|
|
20
|
+
const key = scopeKey(scope);
|
|
21
|
+
const queue = queues.get(key) ?? [];
|
|
22
|
+
queue.push(t);
|
|
23
|
+
queues.set(key, queue);
|
|
16
24
|
}
|
|
17
25
|
/** Take everything queued (FIFO), clearing the queue. */
|
|
18
|
-
export function drainReminders() {
|
|
19
|
-
|
|
26
|
+
export function drainReminders(scope) {
|
|
27
|
+
const key = scopeKey(scope);
|
|
28
|
+
const queue = queues.get(key);
|
|
29
|
+
if (!queue?.length)
|
|
20
30
|
return [];
|
|
21
|
-
|
|
31
|
+
queues.delete(key);
|
|
32
|
+
return queue;
|
|
33
|
+
}
|
|
34
|
+
/** Drop an ephemeral session's pending reminders without exposing them to another run. */
|
|
35
|
+
export function disposeReminderScope(scope) {
|
|
36
|
+
queues.delete(scopeKey(scope));
|
|
22
37
|
}
|
|
23
38
|
/** Merge queued reminders into the single injected message. */
|
|
24
39
|
export function wrapReminders(items) {
|
|
@@ -4,8 +4,16 @@
|
|
|
4
4
|
// covers FAILED ones. Deterministic and session-scoped (module state, same pattern as net-reachability):
|
|
5
5
|
// when an identical (tool, args) call fails twice in a row, the tool result gets an explicit "stop
|
|
6
6
|
// repeating this" note the model can't miss. Successful repeats are NOT flagged — a re-read after an edit
|
|
7
|
-
// or a re-run after a fix is legitimate, and a success resets the failure streak.
|
|
8
|
-
|
|
7
|
+
// or a re-run after a fix is legitimate, and a success resets the failure streak. Serve can run several
|
|
8
|
+
// sessions in one process, so streaks are keyed by the same run scope as todo/reminder state.
|
|
9
|
+
const DEFAULT_SCOPE = "default";
|
|
10
|
+
const seenByScope = new Map();
|
|
11
|
+
function scopedSeen(scope) {
|
|
12
|
+
const key = scope?.trim() || DEFAULT_SCOPE;
|
|
13
|
+
const seen = seenByScope.get(key) ?? new Map();
|
|
14
|
+
seenByScope.set(key, seen);
|
|
15
|
+
return seen;
|
|
16
|
+
}
|
|
9
17
|
/** Identity of a call = tool name + exact JSON of its arguments (tool names contain no spaces,
|
|
10
18
|
* so a space separator is unambiguous). */
|
|
11
19
|
export function keyOf(name, input) {
|
|
@@ -24,12 +32,20 @@ export function looksFailed(content) {
|
|
|
24
32
|
}
|
|
25
33
|
/** Record a completed call; returns a warning to APPEND to the tool result when the same call has now
|
|
26
34
|
* failed >=2x in a row (empty string otherwise). Pure aside from the session-scoped map. */
|
|
27
|
-
export function recordCall(name, input, content, isError = false) {
|
|
35
|
+
export function recordCall(name, input, content, isError = false, scope) {
|
|
28
36
|
const k = keyOf(name, input);
|
|
29
37
|
const failed = isError || looksFailed(content);
|
|
38
|
+
const seen = scopedSeen(scope);
|
|
30
39
|
const s = seen.get(k) ?? { fails: 0 };
|
|
31
|
-
|
|
32
|
-
|
|
40
|
+
if (!failed) {
|
|
41
|
+
seen.delete(k); // successes have no useful state; don't leak every unique tool call in a long-lived server
|
|
42
|
+
return "";
|
|
43
|
+
}
|
|
44
|
+
s.fails++;
|
|
45
|
+
seen.delete(k);
|
|
46
|
+
seen.set(k, s); // refresh insertion order for the bounded per-scope LRU
|
|
47
|
+
if (seen.size > 500)
|
|
48
|
+
seen.delete(seen.keys().next().value);
|
|
33
49
|
if (s.fails < 2)
|
|
34
50
|
return "";
|
|
35
51
|
return (`\n\n⟳ hara: this exact ${name} call has now FAILED ${s.fails}× with identical arguments — ` +
|
|
@@ -37,6 +53,9 @@ export function recordCall(name, input, content, isError = false) {
|
|
|
37
53
|
`or step back and re-plan; if you're out of ideas, ask the user and say what you tried.`);
|
|
38
54
|
}
|
|
39
55
|
/** Clear the streaks — /reset (fresh start) and tests. */
|
|
40
|
-
export function resetRepeatGuard() {
|
|
41
|
-
|
|
56
|
+
export function resetRepeatGuard(scope) {
|
|
57
|
+
if (scope)
|
|
58
|
+
seenByScope.delete(scope.trim() || DEFAULT_SCOPE);
|
|
59
|
+
else
|
|
60
|
+
seenByScope.clear();
|
|
42
61
|
}
|