@nanhara/hara 0.125.3 → 0.126.1
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 +59 -1
- package/README.md +10 -4
- package/SECURITY.md +7 -3
- package/dist/agent/limits.js +2 -2
- package/dist/agent/loop.js +270 -98
- package/dist/config.js +172 -4
- package/dist/gateway/flows-pending.js +14 -36
- package/dist/gateway/wecom.js +9 -1
- package/dist/index.js +372 -159
- package/dist/mcp/client.js +2 -0
- package/dist/plugins/manifest.js +317 -0
- package/dist/plugins/plugins.js +476 -139
- package/dist/providers/bounded-turn.js +6 -0
- package/dist/providers/factory.js +54 -0
- package/dist/providers/openai.js +6 -1
- package/dist/providers/registry.js +1 -0
- package/dist/providers/target.js +98 -0
- package/dist/security/guardian.js +6 -1
- package/dist/security/private-state.js +1 -1
- package/dist/security/secrets.js +19 -0
- package/dist/serve/protocol.js +7 -2
- package/dist/serve/server.js +139 -35
- package/dist/serve/sessions.js +11 -2
- package/dist/session/operation-drain.js +45 -0
- package/dist/tools/agent.js +1 -0
- package/dist/tools/all.js +1 -0
- package/dist/tools/ask_user.js +8 -3
- package/dist/tools/builtin.js +19 -1
- package/dist/tools/codebase.js +1 -0
- package/dist/tools/computer.js +1 -0
- package/dist/tools/cron.js +10 -0
- package/dist/tools/external_agent.js +1 -0
- package/dist/tools/memory.js +2 -0
- package/dist/tools/registry.js +95 -4
- package/dist/tools/result-limit.js +172 -2
- package/dist/tools/runtime.js +67 -0
- package/dist/tools/search.js +3 -0
- package/dist/tools/skill.js +1 -0
- package/dist/tools/task.js +5 -0
- package/dist/tools/todo.js +3 -2
- package/dist/tools/web.js +4 -0
- package/dist/tui/App.js +5 -1
- package/package.json +1 -1
package/dist/tools/ask_user.js
CHANGED
|
@@ -6,11 +6,11 @@
|
|
|
6
6
|
// the TUI. In headless / non-TTY / `-p` / gateway runs there is no interactive user (ctx.ask is absent) — the
|
|
7
7
|
// tool returns a clear "proceed with your best judgment" string instead of hanging. kind:"read" so it never
|
|
8
8
|
// itself triggers the approval gate (the interaction IS the prompt).
|
|
9
|
-
import { registerTool } from "./registry.js";
|
|
9
|
+
import { getTool, registerTool } from "./registry.js";
|
|
10
10
|
/** Returned when nobody can answer (headless / non-TTY / -p / gateway / sub-agent). Phrased so the model
|
|
11
11
|
* keeps going on its own judgment rather than re-asking or stalling. */
|
|
12
12
|
export const NO_INTERACTIVE_USER = "(no interactive user available — proceed with your best judgment)";
|
|
13
|
-
|
|
13
|
+
const definition = {
|
|
14
14
|
name: "ask_user",
|
|
15
15
|
description: "Ask the user ONE structured question mid-turn and wait for their answer, then continue. " +
|
|
16
16
|
"Use this ONLY when you are genuinely blocked on a decision that ONLY the user can make — an ambiguous " +
|
|
@@ -23,6 +23,7 @@ registerTool({
|
|
|
23
23
|
"In a non-interactive run (no terminal) it returns a 'proceed with your best judgment' note instead of " +
|
|
24
24
|
"blocking, so prefer making a reasonable call over asking when context already answers the question.",
|
|
25
25
|
kind: "read", // the prompt itself is the interaction; never route it through the approval gate
|
|
26
|
+
classify: () => ({ effect: "interactive", concurrencySafe: false }),
|
|
26
27
|
input_schema: {
|
|
27
28
|
type: "object",
|
|
28
29
|
properties: {
|
|
@@ -65,4 +66,8 @@ registerTool({
|
|
|
65
66
|
return `${NO_INTERACTIVE_USER} (ask failed: ${e?.message ?? e})`;
|
|
66
67
|
}
|
|
67
68
|
},
|
|
68
|
-
}
|
|
69
|
+
};
|
|
70
|
+
registerTool(definition);
|
|
71
|
+
/** Exact registered identity used by the agent loop to grant the engine-owned prompt its pausable
|
|
72
|
+
* active-budget semantics. A plugin that later shadows the public name does not inherit that authority. */
|
|
73
|
+
export const askUserTool = getTool(definition.name);
|
package/dist/tools/builtin.js
CHANGED
|
@@ -13,6 +13,7 @@ import { BinaryFileError, readVerifiedRegularFileSnapshot, resolveVerifiedModelP
|
|
|
13
13
|
import { startJob, listJobs, tailJob, killJob } from "../exec/jobs.js";
|
|
14
14
|
import { sensitiveFileError, sensitiveShellCommandReason } from "../security/sensitive-files.js";
|
|
15
15
|
import { createToolOutputLineRedactor, redactToolSubprocessOutput } from "../security/subprocess-env.js";
|
|
16
|
+
import { isReadOnlyCommand, splitCompound } from "../security/permissions.js";
|
|
16
17
|
import { hostsInCommand, isNetworkGitOp, hostFromConnectError, isConnectFailure, markHostUnreachable, isHostUnreachable, unreachableHostsSnapshot, } from "./net-reachability.js";
|
|
17
18
|
const MAX = 100_000;
|
|
18
19
|
/** Package installs are network-bound and routinely exceed the ordinary foreground cap. */
|
|
@@ -123,6 +124,7 @@ registerTool({
|
|
|
123
124
|
required: ["path"],
|
|
124
125
|
},
|
|
125
126
|
kind: "read",
|
|
127
|
+
concurrencySafe: true,
|
|
126
128
|
async run(input, ctx) {
|
|
127
129
|
const p = abs(input.path, ctx.cwd);
|
|
128
130
|
const denied = sensitiveFileError(p, "read");
|
|
@@ -211,6 +213,16 @@ registerTool({
|
|
|
211
213
|
required: ["command"],
|
|
212
214
|
},
|
|
213
215
|
kind: "exec",
|
|
216
|
+
classify(input) {
|
|
217
|
+
const command = typeof input?.command === "string" ? input.command : "";
|
|
218
|
+
const parts = command ? splitCompound(command) : null;
|
|
219
|
+
const readOnly = !Boolean(input?.background)
|
|
220
|
+
&& !!parts?.length
|
|
221
|
+
&& parts.every(isReadOnlyCommand);
|
|
222
|
+
return readOnly
|
|
223
|
+
? { effect: "read", concurrencySafe: true }
|
|
224
|
+
: { effect: "exec", concurrencySafe: false };
|
|
225
|
+
},
|
|
214
226
|
requiresProjectWorkspace: true,
|
|
215
227
|
async run(input, ctx) {
|
|
216
228
|
if (input.background !== undefined && typeof input.background !== "boolean") {
|
|
@@ -310,7 +322,13 @@ registerTool({
|
|
|
310
322
|
},
|
|
311
323
|
required: ["action"],
|
|
312
324
|
},
|
|
313
|
-
kind: "
|
|
325
|
+
kind: "exec", // conservative default; list/tail are downgraded per input, kill remains approval-gated
|
|
326
|
+
classify(input) {
|
|
327
|
+
const action = String(input?.action ?? "");
|
|
328
|
+
return action === "list" || action === "tail"
|
|
329
|
+
? { effect: "read", concurrencySafe: true }
|
|
330
|
+
: { effect: "exec", concurrencySafe: false, destructive: action === "kill" };
|
|
331
|
+
},
|
|
314
332
|
async run(input) {
|
|
315
333
|
const action = String(input.action);
|
|
316
334
|
if (action === "list") {
|
package/dist/tools/codebase.js
CHANGED
package/dist/tools/computer.js
CHANGED
|
@@ -391,6 +391,7 @@ if (!process.env.HARA_GATEWAY || process.env.HARA_GATEWAY_COMPUTER === "1") {
|
|
|
391
391
|
required: ["action"],
|
|
392
392
|
},
|
|
393
393
|
kind: "computer",
|
|
394
|
+
visibility: "deferred",
|
|
394
395
|
async run(input, ctx) {
|
|
395
396
|
if (ctx.signal?.aborted)
|
|
396
397
|
throw new ComputerInterruptedError();
|
package/dist/tools/cron.js
CHANGED
|
@@ -62,6 +62,16 @@ registerTool({
|
|
|
62
62
|
required: ["action"],
|
|
63
63
|
},
|
|
64
64
|
kind: "exec", // scheduling machinery on the user's machine — approval-gated like any exec
|
|
65
|
+
visibility: "deferred",
|
|
66
|
+
classify(input) {
|
|
67
|
+
return input?.action === "list"
|
|
68
|
+
? { effect: "read", concurrencySafe: true }
|
|
69
|
+
: {
|
|
70
|
+
effect: "exec",
|
|
71
|
+
concurrencySafe: false,
|
|
72
|
+
destructive: input?.action === "remove" || input?.action === "disable",
|
|
73
|
+
};
|
|
74
|
+
},
|
|
65
75
|
async run(input, ctx) {
|
|
66
76
|
if (process.env.HARA_CRON === "1")
|
|
67
77
|
return "Error: cron-run sessions cannot manage cron jobs (recursion guard — a scheduled task must not schedule more tasks).";
|
|
@@ -109,6 +109,7 @@ registerTool({
|
|
|
109
109
|
"another agent to own end-to-end. It can read/write/run on the host, so it's gated by approval. " +
|
|
110
110
|
"Args: task (required), backend (claude|codex; default = first installed), model (optional).",
|
|
111
111
|
kind: "exec", // → approval gate; never exposed to read-only fan-out sub-agents
|
|
112
|
+
visibility: "deferred",
|
|
112
113
|
requiresProjectWorkspace: true,
|
|
113
114
|
trustBoundary: "external",
|
|
114
115
|
input_schema: {
|
package/dist/tools/memory.js
CHANGED
|
@@ -26,6 +26,7 @@ registerTool({
|
|
|
26
26
|
required: ["query"],
|
|
27
27
|
},
|
|
28
28
|
kind: "read",
|
|
29
|
+
concurrencySafe: true,
|
|
29
30
|
async run(input, ctx) {
|
|
30
31
|
const hits = await searchHybrid(String(input.query ?? ""), ctx.cwd, { indexName: "memory", roots: memoryRoots(ctx.cwd), limit: Math.min(Number(input.limit) || 5, 10), signal: ctx.signal });
|
|
31
32
|
if (!hits.length)
|
|
@@ -38,6 +39,7 @@ registerTool({
|
|
|
38
39
|
description: "Read a memory file in full (use after memory_search to pull the exact entry).",
|
|
39
40
|
input_schema: { type: "object", properties: { path: { type: "string" } }, required: ["path"] },
|
|
40
41
|
kind: "read",
|
|
42
|
+
concurrencySafe: true,
|
|
41
43
|
async run(input, ctx) {
|
|
42
44
|
const p = isAbsolute(String(input.path)) ? String(input.path) : resolve(ctx.cwd, String(input.path));
|
|
43
45
|
if (!memoryRoots(ctx.cwd).some((root) => {
|
package/dist/tools/registry.js
CHANGED
|
@@ -1,4 +1,4 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { prepareToolResult } from "./result-limit.js";
|
|
2
2
|
import { homeWorkspaceActionError, isUnsafeProjectWorkspace } from "../context/workspace-scope.js";
|
|
3
3
|
/** Names of required parameters that are ABSENT (undefined/null) in a tool call's input. Defends
|
|
4
4
|
* against models that drop parameters outright (observed: qwen3.7-plus losing write_file's
|
|
@@ -31,7 +31,10 @@ export function registerTool(t) {
|
|
|
31
31
|
if (t.requiresProjectWorkspace && isUnsafeProjectWorkspace(ctx.cwd)) {
|
|
32
32
|
return `Error: ${homeWorkspaceActionError(`run ${t.name}`)}`;
|
|
33
33
|
}
|
|
34
|
-
|
|
34
|
+
// Verified read_file content already passed the protected-file policy. Preserve its historical
|
|
35
|
+
// explicit opt-in semantics and harmless template placeholders in the immediate preview; oversized
|
|
36
|
+
// continuation storage is still independently redacted by storeToolResult().
|
|
37
|
+
return prepareToolResult(await run(input, ctx), undefined, { redactPreview: t.name !== "read_file" });
|
|
35
38
|
},
|
|
36
39
|
});
|
|
37
40
|
specsCache = null;
|
|
@@ -42,8 +45,53 @@ export function getTool(name) {
|
|
|
42
45
|
export function getTools() {
|
|
43
46
|
return [...registry.values()];
|
|
44
47
|
}
|
|
48
|
+
function defaultEffect(tool) {
|
|
49
|
+
if (tool.kind === "edit")
|
|
50
|
+
return "edit";
|
|
51
|
+
if (tool.kind === "exec")
|
|
52
|
+
return "exec";
|
|
53
|
+
if (tool.kind === "computer")
|
|
54
|
+
return "computer";
|
|
55
|
+
if (tool.kind === "read")
|
|
56
|
+
return "read";
|
|
57
|
+
// Legacy/plugin tools that omitted their safety declaration used to remain approval-gated. Preserve that
|
|
58
|
+
// conservative boundary: missing metadata must never silently mean read-only.
|
|
59
|
+
return "exec";
|
|
60
|
+
}
|
|
61
|
+
const TOOL_EFFECTS = new Set(["read", "state", "edit", "exec", "computer", "interactive"]);
|
|
62
|
+
/** Conservative, non-throwing classifier shared by approval, understanding, guardian, and scheduling. */
|
|
63
|
+
export function toolOperationTraits(tool, input, ctx) {
|
|
64
|
+
const effect = defaultEffect(tool);
|
|
65
|
+
const fallback = { effect, concurrencySafe: tool.concurrencySafe === true };
|
|
66
|
+
if (!tool.classify)
|
|
67
|
+
return fallback;
|
|
68
|
+
try {
|
|
69
|
+
const classified = tool.classify(input, ctx);
|
|
70
|
+
if (!classified || typeof classified !== "object" || !TOOL_EFFECTS.has(classified.effect)) {
|
|
71
|
+
return { ...fallback, concurrencySafe: false };
|
|
72
|
+
}
|
|
73
|
+
return {
|
|
74
|
+
effect: classified.effect,
|
|
75
|
+
concurrencySafe: classified.concurrencySafe === true,
|
|
76
|
+
...(classified.destructive === true ? { destructive: true } : {}),
|
|
77
|
+
};
|
|
78
|
+
}
|
|
79
|
+
catch {
|
|
80
|
+
// A failed classifier may never downgrade into parallel execution.
|
|
81
|
+
return { ...fallback, concurrencySafe: false };
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
export function approvalKindForOperation(traits) {
|
|
85
|
+
if (traits.effect === "edit")
|
|
86
|
+
return "edit";
|
|
87
|
+
if (traits.effect === "exec")
|
|
88
|
+
return "exec";
|
|
89
|
+
if (traits.effect === "computer")
|
|
90
|
+
return "computer";
|
|
91
|
+
return "read";
|
|
92
|
+
}
|
|
45
93
|
/** Provider-neutral tool specs derived from the registry. */
|
|
46
|
-
export function toolSpecs() {
|
|
94
|
+
export function toolSpecs(options) {
|
|
47
95
|
if (!specsCache) {
|
|
48
96
|
specsCache = getTools().map((t) => ({
|
|
49
97
|
name: t.name,
|
|
@@ -51,7 +99,50 @@ export function toolSpecs() {
|
|
|
51
99
|
input_schema: t.input_schema,
|
|
52
100
|
}));
|
|
53
101
|
}
|
|
102
|
+
const visible = options === undefined || options.includeDeferred
|
|
103
|
+
? specsCache
|
|
104
|
+
: specsCache.filter((spec) => {
|
|
105
|
+
const tool = registry.get(spec.name);
|
|
106
|
+
return tool?.visibility !== "deferred" || options.activatedDeferred?.has(spec.name);
|
|
107
|
+
});
|
|
54
108
|
// Callers commonly filter the array for a role. Return a shallow copy so that never mutates the
|
|
55
109
|
// stable cached snapshot shared by subsequent agent rounds.
|
|
56
|
-
return
|
|
110
|
+
return visible.slice();
|
|
111
|
+
}
|
|
112
|
+
function catalogTerms(value) {
|
|
113
|
+
return value.toLowerCase().split(/[^a-z0-9_\-\u3400-\u9fff]+/u).filter(Boolean);
|
|
114
|
+
}
|
|
115
|
+
/** Search deferred tools without exposing every JSON schema. Names rank above descriptions. */
|
|
116
|
+
export function searchDeferredToolCatalog(query, limit = 8) {
|
|
117
|
+
const normalized = query.trim().toLowerCase();
|
|
118
|
+
if (!normalized)
|
|
119
|
+
return [];
|
|
120
|
+
const terms = catalogTerms(normalized);
|
|
121
|
+
const matches = [];
|
|
122
|
+
for (const tool of getTools()) {
|
|
123
|
+
if (tool.visibility !== "deferred")
|
|
124
|
+
continue;
|
|
125
|
+
const name = tool.name.toLowerCase();
|
|
126
|
+
const description = tool.description.toLowerCase();
|
|
127
|
+
let score = 0;
|
|
128
|
+
if (name === normalized)
|
|
129
|
+
score += 100;
|
|
130
|
+
if (name.includes(normalized))
|
|
131
|
+
score += 30;
|
|
132
|
+
if (description.includes(normalized))
|
|
133
|
+
score += 15;
|
|
134
|
+
for (const term of terms) {
|
|
135
|
+
if (name === term)
|
|
136
|
+
score += 20;
|
|
137
|
+
else if (name.includes(term))
|
|
138
|
+
score += 8;
|
|
139
|
+
if (description.includes(term))
|
|
140
|
+
score += 3;
|
|
141
|
+
}
|
|
142
|
+
if (score > 0)
|
|
143
|
+
matches.push({ name: tool.name, description: tool.description, score });
|
|
144
|
+
}
|
|
145
|
+
return matches
|
|
146
|
+
.sort((a, b) => b.score - a.score || a.name.localeCompare(b.name))
|
|
147
|
+
.slice(0, Math.max(1, Math.min(32, Math.floor(limit))));
|
|
57
148
|
}
|
|
@@ -1,6 +1,21 @@
|
|
|
1
|
-
//
|
|
2
|
-
//
|
|
1
|
+
// Tool results cross two boundaries: the model needs a bounded preview, while useful command/MCP output
|
|
2
|
+
// must remain recoverable. Oversized redacted values are therefore written to Hara's private state and
|
|
3
|
+
// represented by an opaque id that the model can page with tool_result_read.
|
|
4
|
+
import { randomBytes } from "node:crypto";
|
|
5
|
+
import { lstatSync, readdirSync } from "node:fs";
|
|
6
|
+
import { homedir } from "node:os";
|
|
7
|
+
import { join } from "node:path";
|
|
8
|
+
import { bindPrivateHaraStateFile, readPrivateStateFileSnapshotSync, removePrivateStateFile, writePrivateStateFileSync, } from "../security/private-state.js";
|
|
9
|
+
import { redactToolSubprocessOutput } from "../security/subprocess-env.js";
|
|
3
10
|
export const MAX_TOOL_RESULT_CHARS = 24_000;
|
|
11
|
+
export const MAX_TOOL_RESULT_BATCH_CHARS = 64_000;
|
|
12
|
+
export const MAX_TOOL_RESULT_READ_CHARS = 18_000;
|
|
13
|
+
export const MAX_STORED_TOOL_RESULT_BYTES = 4 * 1024 * 1024;
|
|
14
|
+
const MAX_STORED_TOOL_RESULT_TOTAL_BYTES = 64 * 1024 * 1024;
|
|
15
|
+
const MAX_STORED_TOOL_RESULT_FILES = 128;
|
|
16
|
+
const STORED_TOOL_RESULT_TTL_MS = 7 * 24 * 60 * 60 * 1000;
|
|
17
|
+
const RESULT_ID = /^tr_[a-f0-9]{32}$/;
|
|
18
|
+
const RESULT_FILE = /^(tr_[a-f0-9]{32})\.txt$/;
|
|
4
19
|
function safeHead(value, end) {
|
|
5
20
|
let at = Math.max(0, Math.min(value.length, end));
|
|
6
21
|
if (at > 0 && /[\uD800-\uDBFF]/.test(value[at - 1] ?? ""))
|
|
@@ -35,3 +50,158 @@ export function limitToolResult(value, max = MAX_TOOL_RESULT_CHARS) {
|
|
|
35
50
|
const tailChars = room - headChars;
|
|
36
51
|
return safeHead(text, headChars) + marker + safeTail(text, text.length - tailChars);
|
|
37
52
|
}
|
|
53
|
+
function resultBinding(id) {
|
|
54
|
+
if (!RESULT_ID.test(id))
|
|
55
|
+
throw new Error("invalid tool result id");
|
|
56
|
+
return bindPrivateHaraStateFile(homedir(), ["tool-results"], `${id}.txt`);
|
|
57
|
+
}
|
|
58
|
+
/** Remove only verified single-link private-state files. Changed, linked, or aliased entries are retained
|
|
59
|
+
* and count against the quota, making a hostile local preseed fail closed. */
|
|
60
|
+
function pruneStore(directory, incomingBytes, now = Date.now()) {
|
|
61
|
+
const entries = [];
|
|
62
|
+
let totalBytes = 0;
|
|
63
|
+
let totalFiles = 0;
|
|
64
|
+
for (const name of readdirSync(directory.path)) {
|
|
65
|
+
if (!RESULT_FILE.test(name))
|
|
66
|
+
continue;
|
|
67
|
+
const path = join(directory.path, name);
|
|
68
|
+
try {
|
|
69
|
+
const info = lstatSync(path);
|
|
70
|
+
totalFiles++;
|
|
71
|
+
totalBytes += info.size;
|
|
72
|
+
if (info.isFile() && !info.isSymbolicLink() && info.nlink === 1) {
|
|
73
|
+
entries.push({ path, size: info.size, mtimeMs: info.mtimeMs });
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
catch {
|
|
77
|
+
// A disappearing or changed entry is not safe to remove.
|
|
78
|
+
}
|
|
79
|
+
}
|
|
80
|
+
entries.sort((a, b) => a.mtimeMs - b.mtimeMs);
|
|
81
|
+
for (const entry of entries) {
|
|
82
|
+
const expired = now - entry.mtimeMs > STORED_TOOL_RESULT_TTL_MS;
|
|
83
|
+
const overQuota = totalFiles >= MAX_STORED_TOOL_RESULT_FILES
|
|
84
|
+
|| totalBytes + incomingBytes > MAX_STORED_TOOL_RESULT_TOTAL_BYTES;
|
|
85
|
+
if (!expired && !overQuota)
|
|
86
|
+
continue;
|
|
87
|
+
try {
|
|
88
|
+
const snapshot = readPrivateStateFileSnapshotSync(entry.path, MAX_STORED_TOOL_RESULT_BYTES);
|
|
89
|
+
if (!snapshot)
|
|
90
|
+
continue;
|
|
91
|
+
removePrivateStateFile(entry.path, snapshot, directory);
|
|
92
|
+
totalFiles--;
|
|
93
|
+
totalBytes -= entry.size;
|
|
94
|
+
}
|
|
95
|
+
catch {
|
|
96
|
+
// Never unlink a path that failed the private-state identity boundary.
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
return (totalFiles < MAX_STORED_TOOL_RESULT_FILES
|
|
100
|
+
&& totalBytes + incomingBytes <= MAX_STORED_TOOL_RESULT_TOTAL_BYTES);
|
|
101
|
+
}
|
|
102
|
+
/** Store only a redacted, bounded UTF-8 value. Failure degrades to an ordinary preview; the original tool
|
|
103
|
+
* action must not fail merely because this optional continuation store is unavailable. */
|
|
104
|
+
export function storeToolResult(value) {
|
|
105
|
+
const raw = typeof value === "string" ? value : String(value ?? "");
|
|
106
|
+
const text = redactToolSubprocessOutput(raw);
|
|
107
|
+
const bytes = Buffer.byteLength(text, "utf8");
|
|
108
|
+
if (!bytes || bytes > MAX_STORED_TOOL_RESULT_BYTES)
|
|
109
|
+
return null;
|
|
110
|
+
try {
|
|
111
|
+
const id = `tr_${randomBytes(16).toString("hex")}`;
|
|
112
|
+
const binding = resultBinding(id);
|
|
113
|
+
if (!pruneStore(binding.directory, bytes))
|
|
114
|
+
return null;
|
|
115
|
+
writePrivateStateFileSync(binding, text);
|
|
116
|
+
return { id, chars: text.length };
|
|
117
|
+
}
|
|
118
|
+
catch {
|
|
119
|
+
return null;
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
function referenceFooter(stored, compact = false) {
|
|
123
|
+
if (compact)
|
|
124
|
+
return `[tool result ${stored.id}; use tool_result_read]`;
|
|
125
|
+
return (`\n…[hara: ${stored.chars} redacted chars stored as ${stored.id}; ` +
|
|
126
|
+
`call tool_result_read with {"id":"${stored.id}","offset":0,"limit":${MAX_TOOL_RESULT_READ_CHARS}} to continue]…`);
|
|
127
|
+
}
|
|
128
|
+
function previewWithReference(text, stored, max) {
|
|
129
|
+
const cap = Math.max(0, Math.floor(max));
|
|
130
|
+
if (!cap)
|
|
131
|
+
return "";
|
|
132
|
+
const footer = referenceFooter(stored);
|
|
133
|
+
if (footer.length + 32 < cap)
|
|
134
|
+
return limitToolResult(text, cap - footer.length) + footer;
|
|
135
|
+
return limitToolResult(referenceFooter(stored, true), cap);
|
|
136
|
+
}
|
|
137
|
+
/** Registry boundary: redact ordinary results and spool them before trimming so direct callers and the main
|
|
138
|
+
* loop receive the same safe representation. Verified file reads may keep their immediate preview because
|
|
139
|
+
* their protected-file policy has already run; continuation storage remains redacted independently. */
|
|
140
|
+
export function prepareToolResult(value, max = MAX_TOOL_RESULT_CHARS, options = {}) {
|
|
141
|
+
const raw = typeof value === "string" ? value : String(value ?? "");
|
|
142
|
+
const text = options.redactPreview === false ? raw : redactToolSubprocessOutput(raw);
|
|
143
|
+
const cap = Math.max(0, Math.floor(max));
|
|
144
|
+
if (text.length <= cap)
|
|
145
|
+
return text;
|
|
146
|
+
const stored = storeToolResult(raw);
|
|
147
|
+
return stored ? previewWithReference(text, stored, cap) : limitToolResult(text, cap);
|
|
148
|
+
}
|
|
149
|
+
function existingResultReference(text) {
|
|
150
|
+
const match = text.match(/\[hara:\s+(\d+)\s+redacted chars stored as (tr_[a-f0-9]{32});/);
|
|
151
|
+
if (!match)
|
|
152
|
+
return null;
|
|
153
|
+
const [, charsText, id] = match;
|
|
154
|
+
try {
|
|
155
|
+
const binding = resultBinding(id);
|
|
156
|
+
const snapshot = readPrivateStateFileSnapshotSync(binding.path, MAX_STORED_TOOL_RESULT_BYTES);
|
|
157
|
+
if (!snapshot)
|
|
158
|
+
return null;
|
|
159
|
+
const chars = Number(charsText);
|
|
160
|
+
return { id, chars: Number.isFinite(chars) ? chars : snapshot.text.length };
|
|
161
|
+
}
|
|
162
|
+
catch {
|
|
163
|
+
return null;
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
/** Bound one parallel tool round. Values reduced only because siblings consumed the round budget receive
|
|
167
|
+
* their own continuation id instead of losing content. */
|
|
168
|
+
export function limitToolResultBatch(values, max = MAX_TOOL_RESULT_BATCH_CHARS) {
|
|
169
|
+
const cap = Math.max(0, Math.floor(max));
|
|
170
|
+
if (values.reduce((sum, value) => sum + value.length, 0) <= cap)
|
|
171
|
+
return [...values];
|
|
172
|
+
if (!values.length)
|
|
173
|
+
return [];
|
|
174
|
+
const allowance = Math.floor(cap / values.length);
|
|
175
|
+
return values.map((value) => {
|
|
176
|
+
if (value.length <= allowance)
|
|
177
|
+
return value;
|
|
178
|
+
const stored = existingResultReference(value) ?? storeToolResult(value);
|
|
179
|
+
return stored ? previewWithReference(value, stored, allowance) : limitToolResult(value, allowance);
|
|
180
|
+
});
|
|
181
|
+
}
|
|
182
|
+
/** Page one opaque result id. Paths are never accepted, and the private reader rejects symlinks/hard links. */
|
|
183
|
+
export function readStoredToolResult(id, offsetValue = 0, limitValue = MAX_TOOL_RESULT_READ_CHARS) {
|
|
184
|
+
if (!RESULT_ID.test(id))
|
|
185
|
+
return "Error: invalid tool result id.";
|
|
186
|
+
const offset = Math.max(0, Math.floor(Number(offsetValue) || 0));
|
|
187
|
+
const limit = Math.min(MAX_TOOL_RESULT_READ_CHARS, Math.max(1, Math.floor(Number(limitValue) || MAX_TOOL_RESULT_READ_CHARS)));
|
|
188
|
+
try {
|
|
189
|
+
const binding = resultBinding(id);
|
|
190
|
+
const snapshot = readPrivateStateFileSnapshotSync(binding.path, MAX_STORED_TOOL_RESULT_BYTES);
|
|
191
|
+
if (!snapshot)
|
|
192
|
+
return `Error: tool result ${id} was not found or has expired. Re-run the narrow source query.`;
|
|
193
|
+
const text = snapshot.text;
|
|
194
|
+
if (offset >= text.length)
|
|
195
|
+
return `(tool result ${id} has ${text.length} chars; offset ${offset} is past the end)`;
|
|
196
|
+
const end = Math.min(text.length, offset + limit);
|
|
197
|
+
let slice = safeTail(text, offset);
|
|
198
|
+
slice = safeHead(slice, end - offset);
|
|
199
|
+
const actualEnd = offset + slice.length;
|
|
200
|
+
const header = `(tool result ${id}: chars ${offset}–${actualEnd} of ${text.length}` +
|
|
201
|
+
`${actualEnd < text.length ? `; continue with offset:${actualEnd}` : ""})\n`;
|
|
202
|
+
return header + slice;
|
|
203
|
+
}
|
|
204
|
+
catch (error) {
|
|
205
|
+
return `Error: cannot read tool result ${id}: ${error instanceof Error ? error.message : String(error)}`;
|
|
206
|
+
}
|
|
207
|
+
}
|
|
@@ -0,0 +1,67 @@
|
|
|
1
|
+
// Provider-neutral deferred tool discovery and continuation reads for oversized tool output.
|
|
2
|
+
// These eager tools replace two context-hostile patterns: exposing every long-tail schema up front, and
|
|
3
|
+
// rerunning a broad command because the useful middle of its output was discarded.
|
|
4
|
+
import { registerTool, searchDeferredToolCatalog } from "./registry.js";
|
|
5
|
+
import { MAX_TOOL_RESULT_READ_CHARS, readStoredToolResult } from "./result-limit.js";
|
|
6
|
+
registerTool({
|
|
7
|
+
name: "tool_search",
|
|
8
|
+
description: "Search Hara's deferred tool catalog when the current task needs a capability that is not already listed. " +
|
|
9
|
+
"Matching tools become available on the NEXT model round without loading every long-tail/MCP schema up front. " +
|
|
10
|
+
"Search by capability or service, for example browser, wechat, spreadsheet, or calendar.",
|
|
11
|
+
input_schema: {
|
|
12
|
+
type: "object",
|
|
13
|
+
properties: {
|
|
14
|
+
query: { type: "string", description: "Capability, service, or action to find." },
|
|
15
|
+
max_results: { type: "integer", minimum: 1, maximum: 8, description: "Maximum matches to activate (default 5)." },
|
|
16
|
+
},
|
|
17
|
+
required: ["query"],
|
|
18
|
+
},
|
|
19
|
+
kind: "read",
|
|
20
|
+
classify: () => ({ effect: "state", concurrencySafe: false }),
|
|
21
|
+
async run(input, ctx) {
|
|
22
|
+
const query = typeof input?.query === "string" ? input.query.trim() : "";
|
|
23
|
+
if (!query)
|
|
24
|
+
return "Error: tool_search needs a non-empty query.";
|
|
25
|
+
const limit = Math.max(1, Math.min(8, Math.floor(Number(input?.max_results) || 5)));
|
|
26
|
+
// Search beyond the display limit because a role filter may reject high-ranked candidates.
|
|
27
|
+
const candidates = searchDeferredToolCatalog(query, 32);
|
|
28
|
+
const selected = [];
|
|
29
|
+
for (const match of candidates) {
|
|
30
|
+
const accepted = ctx.activateTools ? ctx.activateTools([match.name]) : [match.name];
|
|
31
|
+
if (!accepted.includes(match.name))
|
|
32
|
+
continue;
|
|
33
|
+
selected.push(match);
|
|
34
|
+
if (selected.length >= limit)
|
|
35
|
+
break;
|
|
36
|
+
}
|
|
37
|
+
if (!selected.length) {
|
|
38
|
+
return `No deferred tools matching "${query}" are available to this run. Use an existing tool or refine the query.`;
|
|
39
|
+
}
|
|
40
|
+
return (`Activated ${selected.length} tool(s) for the next model round:\n` +
|
|
41
|
+
selected.map((match) => `- ${match.name} — ${match.description.slice(0, 300)}`).join("\n"));
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
registerTool({
|
|
45
|
+
name: "tool_result_read",
|
|
46
|
+
description: "Read the next bounded character slice of an oversized tool result stored under an opaque tr_* id. " +
|
|
47
|
+
"Use the id and continuation offset from the truncation notice; this tool never accepts filesystem paths.",
|
|
48
|
+
input_schema: {
|
|
49
|
+
type: "object",
|
|
50
|
+
properties: {
|
|
51
|
+
id: { type: "string", pattern: "^tr_[a-f0-9]{32}$", description: "Opaque result id from a tool output." },
|
|
52
|
+
offset: { type: "integer", minimum: 0, description: "0-based character offset (default 0)." },
|
|
53
|
+
limit: {
|
|
54
|
+
type: "integer",
|
|
55
|
+
minimum: 1,
|
|
56
|
+
maximum: MAX_TOOL_RESULT_READ_CHARS,
|
|
57
|
+
description: `Characters to return (default/max ${MAX_TOOL_RESULT_READ_CHARS}).`,
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
required: ["id"],
|
|
61
|
+
},
|
|
62
|
+
kind: "read",
|
|
63
|
+
concurrencySafe: true,
|
|
64
|
+
async run(input) {
|
|
65
|
+
return readStoredToolResult(String(input?.id ?? ""), input?.offset, input?.limit);
|
|
66
|
+
},
|
|
67
|
+
});
|
package/dist/tools/search.js
CHANGED
|
@@ -522,6 +522,7 @@ registerTool({
|
|
|
522
522
|
required: ["pattern"],
|
|
523
523
|
},
|
|
524
524
|
kind: "read",
|
|
525
|
+
concurrencySafe: true,
|
|
525
526
|
async run(input, ctx) {
|
|
526
527
|
const pattern = typeof input.pattern === "string" ? input.pattern : "";
|
|
527
528
|
if (pattern.length > MAX_PATTERN_CHARS)
|
|
@@ -606,6 +607,7 @@ registerTool({
|
|
|
606
607
|
required: ["pattern"],
|
|
607
608
|
},
|
|
608
609
|
kind: "read",
|
|
610
|
+
concurrencySafe: true,
|
|
609
611
|
async run(input, ctx) {
|
|
610
612
|
const root = absOf(input.path, ctx.cwd);
|
|
611
613
|
const denied = sensitiveFileError(root, "search");
|
|
@@ -672,6 +674,7 @@ registerTool({
|
|
|
672
674
|
properties: { path: { type: "string", description: "directory (default: cwd)" } },
|
|
673
675
|
},
|
|
674
676
|
kind: "read",
|
|
677
|
+
concurrencySafe: true,
|
|
675
678
|
async run(input, ctx) {
|
|
676
679
|
if (isHomeWorkspace(ctx.cwd))
|
|
677
680
|
return homeWorkspaceDirectoryScanError("ls");
|
package/dist/tools/skill.js
CHANGED
|
@@ -11,6 +11,7 @@ registerTool({
|
|
|
11
11
|
"call this to get a skill's steps before performing a task it covers, then follow them.",
|
|
12
12
|
input_schema: { type: "object", properties: { id: { type: "string", description: "the skill id from the Skills list" } }, required: ["id"] },
|
|
13
13
|
kind: "read",
|
|
14
|
+
concurrencySafe: true,
|
|
14
15
|
async run(input, ctx) {
|
|
15
16
|
const id = String(input.id ?? "").trim();
|
|
16
17
|
const sk = loadSkillIndex(ctx.cwd).find((s) => s.id === id);
|
package/dist/tools/task.js
CHANGED
|
@@ -436,6 +436,11 @@ registerTool({
|
|
|
436
436
|
required: ["action"],
|
|
437
437
|
},
|
|
438
438
|
kind: "edit",
|
|
439
|
+
classify(input) {
|
|
440
|
+
return input?.action === "list"
|
|
441
|
+
? { effect: "read", concurrencySafe: true }
|
|
442
|
+
: { effect: "edit", concurrencySafe: false, destructive: input?.action === "remove" };
|
|
443
|
+
},
|
|
439
444
|
async run(input, ctx) {
|
|
440
445
|
try {
|
|
441
446
|
return withTaskLock(ctx.cwd, () => {
|
package/dist/tools/todo.js
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
// todo_write — an inline task checklist the agent maintains during a turn (like codex's update_plan /
|
|
2
2
|
// Claude Code's TodoWrite). Keeps the model organized on multi-step work and shows the user live progress.
|
|
3
|
-
// In-memory, replace-whole-list semantics;
|
|
3
|
+
// In-memory, replace-whole-list semantics; approval-safe but serialized because it mutates shared run state.
|
|
4
4
|
import { registerTool } from "./registry.js";
|
|
5
5
|
const DEFAULT_SCOPE = "default";
|
|
6
6
|
const stores = new Map([[DEFAULT_SCOPE, []]]);
|
|
@@ -133,7 +133,8 @@ registerTool({
|
|
|
133
133
|
},
|
|
134
134
|
required: ["todos"],
|
|
135
135
|
},
|
|
136
|
-
kind: "read", //
|
|
136
|
+
kind: "read", // state/display only: never prompts; input-level traits keep replacement writes serial
|
|
137
|
+
classify: () => ({ effect: "state", concurrencySafe: false }),
|
|
137
138
|
async run(input, ctx) {
|
|
138
139
|
const scope = scopeKey(ctx.todoScope);
|
|
139
140
|
const raw = Array.isArray(input.todos) ? input.todos : [];
|
package/dist/tools/web.js
CHANGED
|
@@ -399,6 +399,8 @@ registerTool({
|
|
|
399
399
|
required: ["query"],
|
|
400
400
|
},
|
|
401
401
|
kind: "read",
|
|
402
|
+
concurrencySafe: true,
|
|
403
|
+
visibility: "deferred",
|
|
402
404
|
async run(input, ctx) {
|
|
403
405
|
if (ctx.signal?.aborted)
|
|
404
406
|
throw interrupted("web search");
|
|
@@ -506,6 +508,8 @@ registerTool({
|
|
|
506
508
|
required: ["url"],
|
|
507
509
|
},
|
|
508
510
|
kind: "read",
|
|
511
|
+
concurrencySafe: true,
|
|
512
|
+
visibility: "deferred",
|
|
509
513
|
async run(input, ctx) {
|
|
510
514
|
if (ctx.signal?.aborted)
|
|
511
515
|
throw interrupted("web fetch");
|
package/dist/tui/App.js
CHANGED
|
@@ -299,7 +299,11 @@ function StatusRow({ working, steerable, todos, queued }) {
|
|
|
299
299
|
const elapsedSec = Math.floor((Date.now() - startRef.current) / 1000);
|
|
300
300
|
// Pre-first-token honesty (codex-parity): "waiting for the model" reads very differently from a
|
|
301
301
|
// generic "working" when the network is slow — the user knows the request is out, not dead.
|
|
302
|
-
const verb = (phase === "
|
|
302
|
+
const verb = (phase === "awaiting_user"
|
|
303
|
+
? "waiting for your answer · task timer paused · esc to cancel"
|
|
304
|
+
: phase === "waiting"
|
|
305
|
+
? `waiting for the model… ${elapsedSec}s · esc to interrupt`
|
|
306
|
+
: spinnerVerb(todos, elapsedSec)) + bgTag;
|
|
303
307
|
return (_jsxs(Box, { marginTop: 1, children: [_jsx(Text, { color: "yellow", children: frames[frame % frames.length] }), _jsx(Text, { dimColor: true, children: ` ${verb} · ${steerable ? "⏎ steers · /next queues" : "⏎ queues next"}${queued ? ` (${queued})` : ""}` })] }));
|
|
304
308
|
}
|
|
305
309
|
// Short per-mode descriptions for the ONE-ROW mode line (the old two-row ModeBar's long sentences
|