@personaxis/core 0.11.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/LICENSE +21 -0
- package/dist/agent.d.ts +105 -0
- package/dist/agent.js +429 -0
- package/dist/appraisal.d.ts +196 -0
- package/dist/appraisal.js +159 -0
- package/dist/approval.d.ts +42 -0
- package/dist/approval.js +71 -0
- package/dist/blackboard.d.ts +86 -0
- package/dist/blackboard.js +139 -0
- package/dist/compile/assemble.d.ts +44 -0
- package/dist/compile/assemble.js +338 -0
- package/dist/compile/dist.d.ts +30 -0
- package/dist/compile/dist.js +66 -0
- package/dist/compile/faithfulness.d.ts +47 -0
- package/dist/compile/faithfulness.js +112 -0
- package/dist/compile/index.d.ts +10 -0
- package/dist/compile/index.js +10 -0
- package/dist/compile/targets.d.ts +62 -0
- package/dist/compile/targets.js +114 -0
- package/dist/config-layers.d.ts +42 -0
- package/dist/config-layers.js +59 -0
- package/dist/config-scan.d.ts +30 -0
- package/dist/config-scan.js +118 -0
- package/dist/context.d.ts +67 -0
- package/dist/context.js +178 -0
- package/dist/envelopes.d.ts +48 -0
- package/dist/envelopes.js +131 -0
- package/dist/events.d.ts +135 -0
- package/dist/events.js +19 -0
- package/dist/evolution-view.d.ts +43 -0
- package/dist/evolution-view.js +67 -0
- package/dist/generated/version.d.ts +1 -0
- package/dist/generated/version.js +2 -0
- package/dist/governance.d.ts +108 -0
- package/dist/governance.js +204 -0
- package/dist/heuristic-appraiser.d.ts +13 -0
- package/dist/heuristic-appraiser.js +48 -0
- package/dist/hooks.d.ts +52 -0
- package/dist/hooks.js +122 -0
- package/dist/index.d.ts +53 -0
- package/dist/index.js +53 -0
- package/dist/injection.d.ts +48 -0
- package/dist/injection.js +141 -0
- package/dist/live-sync.d.ts +60 -0
- package/dist/live-sync.js +85 -0
- package/dist/llm-appraiser.d.ts +31 -0
- package/dist/llm-appraiser.js +143 -0
- package/dist/lock.d.ts +26 -0
- package/dist/lock.js +99 -0
- package/dist/loop.d.ts +52 -0
- package/dist/loop.js +253 -0
- package/dist/memory-kinds.d.ts +67 -0
- package/dist/memory-kinds.js +106 -0
- package/dist/memory.d.ts +123 -0
- package/dist/memory.js +228 -0
- package/dist/model-config.d.ts +60 -0
- package/dist/model-config.js +102 -0
- package/dist/persona-theme.d.ts +57 -0
- package/dist/persona-theme.js +183 -0
- package/dist/persona.d.ts +88 -0
- package/dist/persona.js +93 -0
- package/dist/ports/index.d.ts +81 -0
- package/dist/ports/index.js +50 -0
- package/dist/provenance.d.ts +63 -0
- package/dist/provenance.js +132 -0
- package/dist/recompile-marker.d.ts +19 -0
- package/dist/recompile-marker.js +36 -0
- package/dist/registry.d.ts +99 -0
- package/dist/registry.js +168 -0
- package/dist/responder.d.ts +47 -0
- package/dist/responder.js +78 -0
- package/dist/sandbox.d.ts +120 -0
- package/dist/sandbox.js +265 -0
- package/dist/self-evolution.d.ts +148 -0
- package/dist/self-evolution.js +364 -0
- package/dist/session-writer.d.ts +52 -0
- package/dist/session-writer.js +104 -0
- package/dist/sessions.d.ts +96 -0
- package/dist/sessions.js +179 -0
- package/dist/sigil.d.ts +41 -0
- package/dist/sigil.js +92 -0
- package/dist/skill-lifecycle.d.ts +74 -0
- package/dist/skill-lifecycle.js +126 -0
- package/dist/skill-review.d.ts +25 -0
- package/dist/skill-review.js +67 -0
- package/dist/spec-edit.d.ts +30 -0
- package/dist/spec-edit.js +137 -0
- package/dist/state-engine.d.ts +40 -0
- package/dist/state-engine.js +50 -0
- package/dist/state-rebuild.d.ts +48 -0
- package/dist/state-rebuild.js +60 -0
- package/dist/sync.d.ts +32 -0
- package/dist/sync.js +95 -0
- package/dist/tool-calling.d.ts +59 -0
- package/dist/tool-calling.js +168 -0
- package/dist/tool-repair.d.ts +20 -0
- package/dist/tool-repair.js +119 -0
- package/dist/tools/exec.d.ts +46 -0
- package/dist/tools/exec.js +132 -0
- package/dist/tools/registry.d.ts +38 -0
- package/dist/tools/registry.js +162 -0
- package/dist/trace.d.ts +63 -0
- package/dist/trace.js +162 -0
- package/dist/verification.d.ts +65 -0
- package/dist/verification.js +190 -0
- package/package.json +38 -0
|
@@ -0,0 +1,168 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-calling client (G1) — provider-agnostic action proposals.
|
|
3
|
+
*
|
|
4
|
+
* Primary path: OpenAI-style native function-calling (`tools` + `tool_choice`),
|
|
5
|
+
* which Cohere/OpenAI/together/etc. expose on /chat/completions. Fallback path
|
|
6
|
+
* (when an endpoint rejects `tools` with a 400): a ReAct-style single-action JSON
|
|
7
|
+
* (`{thought, tool, args}`) under the same constrained-decoding strategy chain the
|
|
8
|
+
* appraiser uses (json_schema → json_object → plain). Either way the model only
|
|
9
|
+
* *proposes* a tool call; the agent loop gates + executes.
|
|
10
|
+
*/
|
|
11
|
+
import { repairToolArgs } from "./tool-repair.js";
|
|
12
|
+
function extractUsage(json) {
|
|
13
|
+
const u = json.usage;
|
|
14
|
+
if (!u)
|
|
15
|
+
return undefined;
|
|
16
|
+
const total = u.total_tokens ?? (u.prompt_tokens ?? 0) + (u.completion_tokens ?? 0);
|
|
17
|
+
return { prompt_tokens: u.prompt_tokens ?? 0, completion_tokens: u.completion_tokens ?? 0, total_tokens: total };
|
|
18
|
+
}
|
|
19
|
+
function url(cfg) {
|
|
20
|
+
return `${cfg.endpoint.replace(/\/$/, "")}/chat/completions`;
|
|
21
|
+
}
|
|
22
|
+
function headers(cfg) {
|
|
23
|
+
return { "content-type": "application/json", ...(cfg.apiKey ? { authorization: `Bearer ${cfg.apiKey}` } : {}) };
|
|
24
|
+
}
|
|
25
|
+
async function safeText(res) {
|
|
26
|
+
try {
|
|
27
|
+
return (await res.text()).slice(0, 300);
|
|
28
|
+
}
|
|
29
|
+
catch {
|
|
30
|
+
return "";
|
|
31
|
+
}
|
|
32
|
+
}
|
|
33
|
+
function parseArgs(raw) {
|
|
34
|
+
try {
|
|
35
|
+
const v = JSON.parse(raw || "{}");
|
|
36
|
+
return v && typeof v === "object" ? v : {};
|
|
37
|
+
}
|
|
38
|
+
catch {
|
|
39
|
+
// FR.10 (OpenClaw port): salvage almost-JSON before giving up — a repaired
|
|
40
|
+
// call saves a full model round-trip on weaker tool-callers.
|
|
41
|
+
const r = repairToolArgs(raw);
|
|
42
|
+
return r.ok && r.value ? r.value : {};
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* Request the next action. `preferFallback` lets the caller skip the native
|
|
47
|
+
* attempt once it has learned the endpoint doesn't support `tools`.
|
|
48
|
+
*/
|
|
49
|
+
export async function requestToolCall(cfg, messages, tools, preferFallback = false) {
|
|
50
|
+
const fetchImpl = cfg.fetchImpl ?? fetch;
|
|
51
|
+
if (!preferFallback) {
|
|
52
|
+
const body = {
|
|
53
|
+
model: cfg.model,
|
|
54
|
+
messages,
|
|
55
|
+
tools: tools.map((t) => ({
|
|
56
|
+
type: "function",
|
|
57
|
+
function: { name: t.name, description: t.description, parameters: t.parameters },
|
|
58
|
+
})),
|
|
59
|
+
tool_choice: "auto",
|
|
60
|
+
temperature: 0.3,
|
|
61
|
+
max_tokens: cfg.maxTokens ?? 1024,
|
|
62
|
+
};
|
|
63
|
+
const res = await fetchImpl(url(cfg), { method: "POST", headers: headers(cfg), body: JSON.stringify(body) });
|
|
64
|
+
if (res.ok) {
|
|
65
|
+
const json = (await res.json());
|
|
66
|
+
const msg = json.choices?.[0]?.message ?? {};
|
|
67
|
+
const toolCalls = (msg.tool_calls ?? []).map((tc) => ({
|
|
68
|
+
id: tc.id,
|
|
69
|
+
name: tc.function.name,
|
|
70
|
+
args: parseArgs(tc.function.arguments),
|
|
71
|
+
}));
|
|
72
|
+
return { text: (msg.content ?? "").trim(), toolCalls, usedFallback: false, usage: extractUsage(json) };
|
|
73
|
+
}
|
|
74
|
+
// Auth/rate/server errors won't be fixed by the fallback — surface them.
|
|
75
|
+
if (res.status === 401 || res.status === 403 || res.status === 429 || res.status >= 500) {
|
|
76
|
+
throw new Error(`tool-calling HTTP ${res.status}: ${await safeText(res)}`);
|
|
77
|
+
}
|
|
78
|
+
// 400/422 → endpoint likely doesn't support `tools`; degrade to ReAct.
|
|
79
|
+
}
|
|
80
|
+
return reactFallback(cfg, messages, tools);
|
|
81
|
+
}
|
|
82
|
+
const REACT_SCHEMA = {
|
|
83
|
+
type: "object",
|
|
84
|
+
additionalProperties: false,
|
|
85
|
+
required: ["tool", "args"],
|
|
86
|
+
properties: {
|
|
87
|
+
thought: { type: "string" },
|
|
88
|
+
tool: { type: "string" },
|
|
89
|
+
args: { type: "object" },
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
/** ReAct fallback: render the transcript to text, ask for ONE JSON action. */
|
|
93
|
+
async function reactFallback(cfg, messages, tools) {
|
|
94
|
+
const fetchImpl = cfg.fetchImpl ?? fetch;
|
|
95
|
+
const toolDocs = tools
|
|
96
|
+
.map((t) => `- ${t.name}(${Object.keys(t.parameters.properties ?? {}).join(", ")}): ${t.description}`)
|
|
97
|
+
.join("\n");
|
|
98
|
+
const transcript = messages
|
|
99
|
+
.map((m) => {
|
|
100
|
+
if (m.role === "tool")
|
|
101
|
+
return `TOOL_RESULT[${m.name ?? ""}]: ${m.content}`;
|
|
102
|
+
if (m.role === "assistant" && m.tool_calls?.length)
|
|
103
|
+
return `ASSISTANT called: ${m.tool_calls.map((tc) => `${tc.function.name}(${tc.function.arguments})`).join(", ")}`;
|
|
104
|
+
return `${m.role.toUpperCase()}: ${m.content}`;
|
|
105
|
+
})
|
|
106
|
+
.join("\n");
|
|
107
|
+
const system = [
|
|
108
|
+
messages.find((m) => m.role === "system")?.content ?? "",
|
|
109
|
+
"",
|
|
110
|
+
"You are an agent that acts ONLY by emitting a single JSON action.",
|
|
111
|
+
"Available tools:",
|
|
112
|
+
toolDocs,
|
|
113
|
+
"",
|
|
114
|
+
'Respond with exactly one JSON object: {"thought": string, "tool": string, "args": object}.',
|
|
115
|
+
'When the task is complete, use {"tool":"finish","args":{"summary":"..."}}.',
|
|
116
|
+
].join("\n");
|
|
117
|
+
const strategies = [
|
|
118
|
+
{ type: "json_schema", json_schema: { name: "agent_action", strict: true, schema: REACT_SCHEMA } },
|
|
119
|
+
{ type: "json_object" },
|
|
120
|
+
undefined,
|
|
121
|
+
];
|
|
122
|
+
let lastErr = "no response";
|
|
123
|
+
for (const responseFormat of strategies) {
|
|
124
|
+
const body = {
|
|
125
|
+
model: cfg.model,
|
|
126
|
+
messages: [
|
|
127
|
+
{ role: "system", content: system },
|
|
128
|
+
{ role: "user", content: `Transcript so far:\n${transcript}\n\nEmit the next action as JSON.` },
|
|
129
|
+
],
|
|
130
|
+
...(responseFormat ? { response_format: responseFormat } : {}),
|
|
131
|
+
temperature: 0.3,
|
|
132
|
+
max_tokens: cfg.maxTokens ?? 1024,
|
|
133
|
+
};
|
|
134
|
+
const res = await fetchImpl(url(cfg), { method: "POST", headers: headers(cfg), body: JSON.stringify(body) });
|
|
135
|
+
if (res.ok) {
|
|
136
|
+
const json = (await res.json());
|
|
137
|
+
const usage = extractUsage(json);
|
|
138
|
+
const content = json.choices?.[0]?.message?.content ?? "{}";
|
|
139
|
+
let parsed;
|
|
140
|
+
try {
|
|
141
|
+
parsed = JSON.parse(content);
|
|
142
|
+
}
|
|
143
|
+
catch {
|
|
144
|
+
const m = content.match(/\{[\s\S]*\}/);
|
|
145
|
+
try {
|
|
146
|
+
parsed = m ? JSON.parse(m[0]) : {};
|
|
147
|
+
}
|
|
148
|
+
catch {
|
|
149
|
+
const r = repairToolArgs(m ? m[0] : content); // FR.10 repair pass
|
|
150
|
+
parsed = r.ok && r.value ? r.value : {};
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
if (!parsed.tool) {
|
|
154
|
+
return { text: parsed.thought ?? content.slice(0, 200), toolCalls: [], usedFallback: true, usage };
|
|
155
|
+
}
|
|
156
|
+
return {
|
|
157
|
+
text: parsed.thought ?? "",
|
|
158
|
+
toolCalls: [{ id: `react_${Date.now()}`, name: parsed.tool, args: parsed.args ?? {} }],
|
|
159
|
+
usedFallback: true,
|
|
160
|
+
usage,
|
|
161
|
+
};
|
|
162
|
+
}
|
|
163
|
+
lastErr = `HTTP ${res.status}: ${await safeText(res)}`;
|
|
164
|
+
if (res.status === 401 || res.status === 403 || res.status === 429 || res.status >= 500)
|
|
165
|
+
break;
|
|
166
|
+
}
|
|
167
|
+
throw new Error(`tool-calling fallback ${lastErr}`);
|
|
168
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-call repair (FR.10 — ported from OpenClaw's self-contained module):
|
|
3
|
+
* salvage malformed / truncated LLM tool-call argument JSON before rejecting
|
|
4
|
+
* it. Weaker models routinely emit almost-JSON — a code fence around it,
|
|
5
|
+
* single quotes, a trailing comma, or a truncation mid-object. Rejecting those
|
|
6
|
+
* costs a full round-trip; a deterministic repair pass recovers most of them.
|
|
7
|
+
*
|
|
8
|
+
* Repairs are CONSERVATIVE and layered; the result reports `repaired: true`
|
|
9
|
+
* so callers can log/trace that the model needed help (a quality signal).
|
|
10
|
+
*/
|
|
11
|
+
export interface RepairResult {
|
|
12
|
+
ok: boolean;
|
|
13
|
+
value?: Record<string, unknown>;
|
|
14
|
+
repaired: boolean;
|
|
15
|
+
/** Which repairs were applied, in order (for tracing). */
|
|
16
|
+
applied: string[];
|
|
17
|
+
error?: string;
|
|
18
|
+
}
|
|
19
|
+
/** Parse tool-call args, repairing common LLM malformations when needed. */
|
|
20
|
+
export declare function repairToolArgs(raw: string): RepairResult;
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool-call repair (FR.10 — ported from OpenClaw's self-contained module):
|
|
3
|
+
* salvage malformed / truncated LLM tool-call argument JSON before rejecting
|
|
4
|
+
* it. Weaker models routinely emit almost-JSON — a code fence around it,
|
|
5
|
+
* single quotes, a trailing comma, or a truncation mid-object. Rejecting those
|
|
6
|
+
* costs a full round-trip; a deterministic repair pass recovers most of them.
|
|
7
|
+
*
|
|
8
|
+
* Repairs are CONSERVATIVE and layered; the result reports `repaired: true`
|
|
9
|
+
* so callers can log/trace that the model needed help (a quality signal).
|
|
10
|
+
*/
|
|
11
|
+
/** Parse tool-call args, repairing common LLM malformations when needed. */
|
|
12
|
+
export function repairToolArgs(raw) {
|
|
13
|
+
const applied = [];
|
|
14
|
+
let text = raw.trim();
|
|
15
|
+
const attempt = () => {
|
|
16
|
+
try {
|
|
17
|
+
const v = JSON.parse(text);
|
|
18
|
+
return v && typeof v === "object" && !Array.isArray(v) ? v : null;
|
|
19
|
+
}
|
|
20
|
+
catch {
|
|
21
|
+
return null;
|
|
22
|
+
}
|
|
23
|
+
};
|
|
24
|
+
// 0. Already valid.
|
|
25
|
+
let v = attempt();
|
|
26
|
+
if (v)
|
|
27
|
+
return { ok: true, value: v, repaired: false, applied };
|
|
28
|
+
// 1. Strip a markdown code fence (```json ... ```).
|
|
29
|
+
const fence = text.match(/^```[a-zA-Z]*\s*\n?([\s\S]*?)\n?```\s*$/);
|
|
30
|
+
if (fence) {
|
|
31
|
+
text = fence[1].trim();
|
|
32
|
+
applied.push("strip-code-fence");
|
|
33
|
+
if ((v = attempt()))
|
|
34
|
+
return { ok: true, value: v, repaired: true, applied };
|
|
35
|
+
}
|
|
36
|
+
// 2. Extract the outermost {...} from surrounding prose.
|
|
37
|
+
const first = text.indexOf("{");
|
|
38
|
+
const last = text.lastIndexOf("}");
|
|
39
|
+
if (first > 0 || (first === 0 && last !== text.length - 1)) {
|
|
40
|
+
if (first !== -1 && last > first) {
|
|
41
|
+
text = text.slice(first, last + 1);
|
|
42
|
+
applied.push("extract-object");
|
|
43
|
+
if ((v = attempt()))
|
|
44
|
+
return { ok: true, value: v, repaired: true, applied };
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
// 3. Normalize single-quoted strings/keys → double quotes (when no double
|
|
48
|
+
// quotes are present at all — avoids corrupting embedded apostrophes).
|
|
49
|
+
if (!text.includes('"') && text.includes("'")) {
|
|
50
|
+
text = text.replace(/'/g, '"');
|
|
51
|
+
applied.push("single-to-double-quotes");
|
|
52
|
+
if ((v = attempt()))
|
|
53
|
+
return { ok: true, value: v, repaired: true, applied };
|
|
54
|
+
}
|
|
55
|
+
// 4. Quote unquoted keys ({key: → {"key":).
|
|
56
|
+
const quotedKeys = text.replace(/([{,]\s*)([A-Za-z_][A-Za-z0-9_]*)(\s*:)/g, '$1"$2"$3');
|
|
57
|
+
if (quotedKeys !== text) {
|
|
58
|
+
text = quotedKeys;
|
|
59
|
+
applied.push("quote-keys");
|
|
60
|
+
if ((v = attempt()))
|
|
61
|
+
return { ok: true, value: v, repaired: true, applied };
|
|
62
|
+
}
|
|
63
|
+
// 5. Remove trailing commas before } or ].
|
|
64
|
+
const noTrailing = text.replace(/,\s*([}\]])/g, "$1");
|
|
65
|
+
if (noTrailing !== text) {
|
|
66
|
+
text = noTrailing;
|
|
67
|
+
applied.push("strip-trailing-commas");
|
|
68
|
+
if ((v = attempt()))
|
|
69
|
+
return { ok: true, value: v, repaired: true, applied };
|
|
70
|
+
}
|
|
71
|
+
// 6. Truncation: close an unterminated string, then balance braces/brackets.
|
|
72
|
+
const closed = closeTruncated(text);
|
|
73
|
+
if (closed !== text) {
|
|
74
|
+
text = closed;
|
|
75
|
+
applied.push("close-truncated");
|
|
76
|
+
if ((v = attempt()))
|
|
77
|
+
return { ok: true, value: v, repaired: true, applied };
|
|
78
|
+
}
|
|
79
|
+
return { ok: false, repaired: applied.length > 0, applied, error: "unrecoverable tool-call JSON" };
|
|
80
|
+
}
|
|
81
|
+
/** Close an unterminated trailing string and balance {} / []. */
|
|
82
|
+
function closeTruncated(text) {
|
|
83
|
+
let inString = false;
|
|
84
|
+
let escaped = false;
|
|
85
|
+
const stack = [];
|
|
86
|
+
for (const ch of text) {
|
|
87
|
+
if (escaped) {
|
|
88
|
+
escaped = false;
|
|
89
|
+
continue;
|
|
90
|
+
}
|
|
91
|
+
if (ch === "\\") {
|
|
92
|
+
escaped = true;
|
|
93
|
+
continue;
|
|
94
|
+
}
|
|
95
|
+
if (ch === '"') {
|
|
96
|
+
inString = !inString;
|
|
97
|
+
continue;
|
|
98
|
+
}
|
|
99
|
+
if (inString)
|
|
100
|
+
continue;
|
|
101
|
+
if (ch === "{")
|
|
102
|
+
stack.push("}");
|
|
103
|
+
else if (ch === "[")
|
|
104
|
+
stack.push("]");
|
|
105
|
+
else if (ch === "}" || ch === "]") {
|
|
106
|
+
if (stack[stack.length - 1] === ch)
|
|
107
|
+
stack.pop();
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
let out = text;
|
|
111
|
+
// A truncated value like `{"a": "unfinish` → close the string first.
|
|
112
|
+
if (inString)
|
|
113
|
+
out += '"';
|
|
114
|
+
// Drop a dangling comma/colon before closing (`{"a": 1,` / `{"a":`).
|
|
115
|
+
out = out.replace(/[,:]\s*$/, (m) => (m.trim() === ":" ? ': null' : ""));
|
|
116
|
+
while (stack.length > 0)
|
|
117
|
+
out += stack.pop();
|
|
118
|
+
return out;
|
|
119
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real action executors (G1) — the surface the governed agent loop acts through.
|
|
3
|
+
*
|
|
4
|
+
* Gating happens BEFORE this module (the agent calls evaluateCommand /
|
|
5
|
+
* evaluateFileWrite and only reaches here on an `allow`). These functions perform
|
|
6
|
+
* the actual side effect, bounded: commands run with a timeout and truncated
|
|
7
|
+
* output; file writes resolve against the workspace root. Output is returned, not
|
|
8
|
+
* printed — the engine stays UI-agnostic.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { type Policy } from "../sandbox.js";
|
|
12
|
+
/** Hard cap on captured stdout/stderr so a runaway command can't blow up context. */
|
|
13
|
+
export declare const MAX_OUTPUT = 16000;
|
|
14
|
+
export interface ExecResult {
|
|
15
|
+
ok: boolean;
|
|
16
|
+
code: number | null;
|
|
17
|
+
stdout: string;
|
|
18
|
+
stderr: string;
|
|
19
|
+
truncated: boolean;
|
|
20
|
+
timedOut: boolean;
|
|
21
|
+
}
|
|
22
|
+
/**
|
|
23
|
+
* Run an ALLOWED shell command. When a native sandbox wrapper is available
|
|
24
|
+
* (macOS Seatbelt / Linux bubblewrap) we spawn that binary directly with explicit
|
|
25
|
+
* args (no shell). Otherwise we run through the OS shell (cmd.exe on Windows,
|
|
26
|
+
* /bin/sh on Unix) so the command is portable.
|
|
27
|
+
*/
|
|
28
|
+
export declare function executeCommand(cmd: string, policy: Policy, opts?: {
|
|
29
|
+
timeoutMs?: number;
|
|
30
|
+
spawnImpl?: typeof spawn;
|
|
31
|
+
}): Promise<ExecResult>;
|
|
32
|
+
export interface FileResult {
|
|
33
|
+
ok: boolean;
|
|
34
|
+
path: string;
|
|
35
|
+
bytes?: number;
|
|
36
|
+
content?: string;
|
|
37
|
+
error?: string;
|
|
38
|
+
}
|
|
39
|
+
/** Overwrite/create a file with content (parent dirs created). */
|
|
40
|
+
export declare function executeFileWrite(path: string, content: string, policy: Policy): FileResult;
|
|
41
|
+
/** Replace the first occurrence of `find` with `replace` in an existing file. */
|
|
42
|
+
export declare function executeFileEdit(path: string, find: string, replace: string, policy: Policy): FileResult;
|
|
43
|
+
/** Read a file's contents (truncated to MAX_OUTPUT). */
|
|
44
|
+
export declare function readFileSafe(path: string, policy: Policy): FileResult;
|
|
45
|
+
/** List a directory (names + type marker). */
|
|
46
|
+
export declare function listDirSafe(path: string, policy: Policy): FileResult;
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Real action executors (G1) — the surface the governed agent loop acts through.
|
|
3
|
+
*
|
|
4
|
+
* Gating happens BEFORE this module (the agent calls evaluateCommand /
|
|
5
|
+
* evaluateFileWrite and only reaches here on an `allow`). These functions perform
|
|
6
|
+
* the actual side effect, bounded: commands run with a timeout and truncated
|
|
7
|
+
* output; file writes resolve against the workspace root. Output is returned, not
|
|
8
|
+
* printed — the engine stays UI-agnostic.
|
|
9
|
+
*/
|
|
10
|
+
import { spawn } from "node:child_process";
|
|
11
|
+
import { existsSync, mkdirSync, readFileSync, readdirSync, statSync, writeFileSync, } from "node:fs";
|
|
12
|
+
import { dirname, resolve } from "node:path";
|
|
13
|
+
import { wrapCommand } from "../sandbox.js";
|
|
14
|
+
/** Hard cap on captured stdout/stderr so a runaway command can't blow up context. */
|
|
15
|
+
export const MAX_OUTPUT = 16_000;
|
|
16
|
+
const DEFAULT_TIMEOUT_MS = 30_000;
|
|
17
|
+
/**
|
|
18
|
+
* Run an ALLOWED shell command. When a native sandbox wrapper is available
|
|
19
|
+
* (macOS Seatbelt / Linux bubblewrap) we spawn that binary directly with explicit
|
|
20
|
+
* args (no shell). Otherwise we run through the OS shell (cmd.exe on Windows,
|
|
21
|
+
* /bin/sh on Unix) so the command is portable.
|
|
22
|
+
*/
|
|
23
|
+
export function executeCommand(cmd, policy, opts = {}) {
|
|
24
|
+
const wrapped = wrapCommand(cmd, policy);
|
|
25
|
+
const useShell = wrapped.sandbox === "none";
|
|
26
|
+
const spawnFn = opts.spawnImpl ?? spawn;
|
|
27
|
+
return new Promise((resolveExec) => {
|
|
28
|
+
const child = useShell
|
|
29
|
+
? spawnFn(cmd, { cwd: policy.workspaceRoot, shell: true })
|
|
30
|
+
: spawnFn(wrapped.command, wrapped.args, { cwd: policy.workspaceRoot, shell: false });
|
|
31
|
+
let stdout = "";
|
|
32
|
+
let stderr = "";
|
|
33
|
+
let truncated = false;
|
|
34
|
+
let timedOut = false;
|
|
35
|
+
let settled = false;
|
|
36
|
+
const finish = (r) => {
|
|
37
|
+
if (settled)
|
|
38
|
+
return;
|
|
39
|
+
settled = true;
|
|
40
|
+
clearTimeout(timer);
|
|
41
|
+
resolveExec(r);
|
|
42
|
+
};
|
|
43
|
+
const timer = setTimeout(() => {
|
|
44
|
+
timedOut = true;
|
|
45
|
+
child.kill("SIGKILL");
|
|
46
|
+
}, opts.timeoutMs ?? DEFAULT_TIMEOUT_MS);
|
|
47
|
+
child.stdout?.on("data", (d) => {
|
|
48
|
+
if (stdout.length < MAX_OUTPUT)
|
|
49
|
+
stdout += d.toString();
|
|
50
|
+
if (stdout.length >= MAX_OUTPUT) {
|
|
51
|
+
stdout = stdout.slice(0, MAX_OUTPUT);
|
|
52
|
+
truncated = true;
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
child.stderr?.on("data", (d) => {
|
|
56
|
+
if (stderr.length < MAX_OUTPUT)
|
|
57
|
+
stderr += d.toString();
|
|
58
|
+
if (stderr.length >= MAX_OUTPUT) {
|
|
59
|
+
stderr = stderr.slice(0, MAX_OUTPUT);
|
|
60
|
+
truncated = true;
|
|
61
|
+
}
|
|
62
|
+
});
|
|
63
|
+
child.on("error", (err) => {
|
|
64
|
+
finish({ ok: false, code: null, stdout, stderr: `${stderr}\n[spawn error: ${err.message}]`, truncated, timedOut });
|
|
65
|
+
});
|
|
66
|
+
child.on("close", (code) => {
|
|
67
|
+
finish({ ok: !timedOut && code === 0, code, stdout, stderr, truncated, timedOut });
|
|
68
|
+
});
|
|
69
|
+
});
|
|
70
|
+
}
|
|
71
|
+
function abs(path, policy) {
|
|
72
|
+
return resolve(policy.workspaceRoot, path);
|
|
73
|
+
}
|
|
74
|
+
/** Overwrite/create a file with content (parent dirs created). */
|
|
75
|
+
export function executeFileWrite(path, content, policy) {
|
|
76
|
+
try {
|
|
77
|
+
const p = abs(path, policy);
|
|
78
|
+
mkdirSync(dirname(p), { recursive: true });
|
|
79
|
+
writeFileSync(p, content, "utf-8");
|
|
80
|
+
return { ok: true, path: p, bytes: Buffer.byteLength(content) };
|
|
81
|
+
}
|
|
82
|
+
catch (e) {
|
|
83
|
+
return { ok: false, path, error: e.message };
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
/** Replace the first occurrence of `find` with `replace` in an existing file. */
|
|
87
|
+
export function executeFileEdit(path, find, replace, policy) {
|
|
88
|
+
try {
|
|
89
|
+
const p = abs(path, policy);
|
|
90
|
+
if (!existsSync(p))
|
|
91
|
+
return { ok: false, path: p, error: "file not found" };
|
|
92
|
+
const orig = readFileSync(p, "utf-8");
|
|
93
|
+
if (!orig.includes(find))
|
|
94
|
+
return { ok: false, path: p, error: "find text not present (no change made)" };
|
|
95
|
+
writeFileSync(p, orig.replace(find, replace), "utf-8");
|
|
96
|
+
return { ok: true, path: p };
|
|
97
|
+
}
|
|
98
|
+
catch (e) {
|
|
99
|
+
return { ok: false, path, error: e.message };
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
/** Read a file's contents (truncated to MAX_OUTPUT). */
|
|
103
|
+
export function readFileSafe(path, policy) {
|
|
104
|
+
try {
|
|
105
|
+
const p = abs(path, policy);
|
|
106
|
+
if (!existsSync(p))
|
|
107
|
+
return { ok: false, path: p, error: "file not found" };
|
|
108
|
+
let content = readFileSync(p, "utf-8");
|
|
109
|
+
if (content.length > MAX_OUTPUT)
|
|
110
|
+
content = content.slice(0, MAX_OUTPUT) + "\n…[truncated]";
|
|
111
|
+
return { ok: true, path: p, content };
|
|
112
|
+
}
|
|
113
|
+
catch (e) {
|
|
114
|
+
return { ok: false, path, error: e.message };
|
|
115
|
+
}
|
|
116
|
+
}
|
|
117
|
+
/** List a directory (names + type marker). */
|
|
118
|
+
export function listDirSafe(path, policy) {
|
|
119
|
+
try {
|
|
120
|
+
const p = abs(path, policy);
|
|
121
|
+
if (!existsSync(p))
|
|
122
|
+
return { ok: false, path: p, error: "directory not found" };
|
|
123
|
+
const entries = readdirSync(p).map((name) => {
|
|
124
|
+
const isDir = statSync(resolve(p, name)).isDirectory();
|
|
125
|
+
return isDir ? `${name}/` : name;
|
|
126
|
+
});
|
|
127
|
+
return { ok: true, path: p, content: entries.join("\n") };
|
|
128
|
+
}
|
|
129
|
+
catch (e) {
|
|
130
|
+
return { ok: false, path, error: e.message };
|
|
131
|
+
}
|
|
132
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Tool registry (G1) — the governed agent's action vocabulary.
|
|
3
|
+
*
|
|
4
|
+
* Each tool declares: a JSON-Schema for its args (used both for native
|
|
5
|
+
* function-calling and the constrained-JSON fallback), a `gate` that returns a
|
|
6
|
+
* sandbox verdict (allow | ask | deny) WITHOUT side effects, and an `execute`
|
|
7
|
+
* that performs the action and returns a text observation to feed back to the
|
|
8
|
+
* model. The agent loop owns the policy and only calls `execute` after the gate
|
|
9
|
+
* (and, for `ask`, the human) approves.
|
|
10
|
+
*/
|
|
11
|
+
import { type CommandVerdict, type Policy } from "../sandbox.js";
|
|
12
|
+
export interface ToolSpec {
|
|
13
|
+
name: string;
|
|
14
|
+
description: string;
|
|
15
|
+
/** JSON Schema for the tool's arguments object — the SINGLE schema source
|
|
16
|
+
* (native function-calling, constrained-JSON fallback, and validateToolArgs
|
|
17
|
+
* all read it; FR.7 decision: no parallel Zod declaration). */
|
|
18
|
+
parameters: Record<string, unknown>;
|
|
19
|
+
/** FR.7: true when the tool cannot change any state — read-only tools may run
|
|
20
|
+
* in PARALLEL; writers run serially (Claude Code's scheduling rule). */
|
|
21
|
+
isReadOnly: boolean;
|
|
22
|
+
/** FR.7: true when concurrent invocations of THIS tool cannot interfere. */
|
|
23
|
+
isConcurrencySafe: boolean;
|
|
24
|
+
/** Decide allow | ask | deny for these args under the policy. Pure. */
|
|
25
|
+
gate(args: Record<string, unknown>, policy: Policy): CommandVerdict;
|
|
26
|
+
/** Perform the action; returns a text observation for the model. */
|
|
27
|
+
execute(args: Record<string, unknown>, policy: Policy): Promise<string>;
|
|
28
|
+
}
|
|
29
|
+
/**
|
|
30
|
+
* FR.7: validate args against the tool's declared JSON Schema (required keys +
|
|
31
|
+
* primitive types — the registry's schemas are flat by design). Returns the
|
|
32
|
+
* problems found; empty = valid. Runs BEFORE the gate, so a malformed call is
|
|
33
|
+
* an input error, never a policy question.
|
|
34
|
+
*/
|
|
35
|
+
export declare function validateToolArgs(spec: ToolSpec, args: Record<string, unknown>): string[];
|
|
36
|
+
export declare const TOOLS: ToolSpec[];
|
|
37
|
+
export declare const FINISH_TOOL = "finish";
|
|
38
|
+
export declare function toolByName(name: string): ToolSpec | undefined;
|