ai-runtime-engine 1.3.0 → 2.8.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 +638 -0
- package/dist/agents/admit.d.ts +69 -0
- package/dist/agents/admit.js +129 -0
- package/dist/agents/definition.d.ts +36 -0
- package/dist/agents/definition.js +9 -0
- package/dist/agents/envelope.d.ts +53 -0
- package/dist/agents/envelope.js +68 -0
- package/dist/agents/finding.d.ts +79 -0
- package/dist/agents/finding.js +80 -0
- package/dist/agents/roles.d.ts +36 -0
- package/dist/agents/roles.js +44 -0
- package/dist/agents/synthesize.d.ts +44 -0
- package/dist/agents/synthesize.js +60 -0
- package/dist/agents/task.d.ts +112 -0
- package/dist/agents/task.js +48 -0
- package/dist/agents/worker.d.ts +91 -0
- package/dist/agents/worker.js +377 -0
- package/dist/capabilities/capability.d.ts +117 -0
- package/dist/capabilities/capability.js +66 -0
- package/dist/capabilities/registry.d.ts +139 -0
- package/dist/capabilities/registry.js +413 -0
- package/dist/capabilities/vocabulary.d.ts +32 -0
- package/dist/capabilities/vocabulary.js +34 -0
- package/dist/cli/cli.js +55 -4
- package/dist/cli/commands/cleanup.js +29 -27
- package/dist/cli/commands/doctor.d.ts +14 -0
- package/dist/cli/commands/doctor.js +38 -8
- package/dist/cli/commands/executions.js +34 -25
- package/dist/cli/commands/info.d.ts +1 -0
- package/dist/cli/commands/info.js +11 -9
- package/dist/cli/commands/init.js +19 -0
- package/dist/cli/commands/inspect.d.ts +40 -1
- package/dist/cli/commands/inspect.js +157 -2
- package/dist/cli/commands/mcp.d.ts +45 -0
- package/dist/cli/commands/mcp.js +148 -0
- package/dist/cli/commands/route.js +21 -0
- package/dist/cli/commands/run.d.ts +1 -0
- package/dist/cli/commands/run.js +21 -2
- package/dist/cli/commands/skills.d.ts +2 -0
- package/dist/cli/commands/skills.js +29 -7
- package/dist/cli/interactive/ansi.d.ts +41 -0
- package/dist/cli/interactive/ansi.js +43 -0
- package/dist/cli/interactive/complete.d.ts +10 -0
- package/dist/cli/interactive/complete.js +19 -0
- package/dist/cli/interactive/lanes.d.ts +69 -0
- package/dist/cli/interactive/lanes.js +181 -0
- package/dist/cli/interactive/repl.d.ts +3 -0
- package/dist/cli/interactive/repl.js +91 -13
- package/dist/cli/interactive/session.d.ts +8 -0
- package/dist/cli/interactive/session.js +73 -2
- package/dist/cli/render.d.ts +7 -0
- package/dist/cli/render.js +10 -0
- package/dist/cli/runtimeSession.d.ts +11 -0
- package/dist/cli/runtimeSession.js +17 -0
- package/dist/config/defaults.d.ts +3 -1
- package/dist/config/defaults.js +2 -0
- package/dist/config/schema.d.ts +1 -0
- package/dist/config/schema.js +2 -2
- package/dist/context/lossVerifier.d.ts +24 -0
- package/dist/context/lossVerifier.js +45 -0
- package/dist/context/summarize.d.ts +19 -0
- package/dist/context/summarize.js +53 -0
- package/dist/core/fallback/fallback.d.ts +5 -0
- package/dist/core/fallback/fallback.js +3 -1
- package/dist/core/router/router.d.ts +3 -0
- package/dist/core/router/router.js +1 -0
- package/dist/executions/agentTasks.d.ts +627 -0
- package/dist/executions/agentTasks.js +149 -0
- package/dist/executions/checkpoint.d.ts +5 -1
- package/dist/executions/checkpoint.js +13 -1
- package/dist/executions/execution.d.ts +36 -2
- package/dist/executions/store.d.ts +37 -0
- package/dist/executions/store.js +33 -0
- package/dist/generation/generateAdapter.d.ts +14 -0
- package/dist/generation/generateAdapter.js +38 -0
- package/dist/generation/generateSkill.d.ts +26 -0
- package/dist/generation/generateSkill.js +51 -0
- package/dist/index.d.ts +47 -4
- package/dist/index.js +33 -2
- package/dist/mcp/client.d.ts +70 -0
- package/dist/mcp/client.js +221 -0
- package/dist/mcp/manager.d.ts +151 -0
- package/dist/mcp/manager.js +493 -0
- package/dist/mcp/protocol.d.ts +216 -0
- package/dist/mcp/protocol.js +149 -0
- package/dist/mcp/toolAdapter.d.ts +44 -0
- package/dist/mcp/toolAdapter.js +94 -0
- package/dist/mcp/transport.d.ts +109 -0
- package/dist/mcp/transport.js +383 -0
- package/dist/memory/embedders/hash.d.ts +12 -0
- package/dist/memory/embedders/hash.js +31 -0
- package/dist/memory/embedders/http.d.ts +25 -0
- package/dist/memory/embedders/http.js +48 -0
- package/dist/memory/memory.d.ts +19 -2
- package/dist/memory/memory.js +75 -11
- package/dist/memory/semantic.d.ts +17 -0
- package/dist/memory/semantic.js +29 -0
- package/dist/orchestration/budget.d.ts +30 -0
- package/dist/orchestration/budget.js +40 -0
- package/dist/orchestration/executor.d.ts +64 -1
- package/dist/orchestration/executor.js +104 -7
- package/dist/orchestration/orchestrator.d.ts +35 -1
- package/dist/orchestration/orchestrator.js +106 -8
- package/dist/orchestration/plan.d.ts +15 -1
- package/dist/orchestration/plan.js +23 -4
- package/dist/orchestration/planner.d.ts +19 -1
- package/dist/orchestration/planner.js +25 -5
- package/dist/plugin/ai.d.ts +4 -0
- package/dist/plugin/ai.js +9 -0
- package/dist/runtime/config.js +50 -6
- package/dist/runtime/intent/aiClassifier.d.ts +19 -0
- package/dist/runtime/intent/aiClassifier.js +74 -0
- package/dist/runtime/models/modelProfile.d.ts +61 -0
- package/dist/runtime/models/modelProfile.js +139 -0
- package/dist/runtime/planning/deriveCapabilities.d.ts +95 -0
- package/dist/runtime/planning/deriveCapabilities.js +146 -0
- package/dist/runtime/policy.d.ts +10 -0
- package/dist/runtime/policy.js +9 -2
- package/dist/runtime/runtime.d.ts +233 -0
- package/dist/runtime/runtime.js +1042 -60
- package/dist/runtime/types.d.ts +88 -2
- package/dist/security/redact.js +22 -10
- package/dist/skills/manifest.d.ts +3 -0
- package/dist/skills/manifest.js +24 -0
- package/dist/skills/registry.d.ts +16 -1
- package/dist/skills/registry.js +21 -1
- package/dist/skills/skill.d.ts +6 -1
- package/dist/store/area.d.ts +15 -1
- package/dist/store/area.js +19 -8
- package/dist/store/crypto.d.ts +21 -0
- package/dist/store/crypto.js +49 -0
- package/dist/store/paths.d.ts +5 -1
- package/dist/store/paths.js +6 -0
- package/dist/store/store.d.ts +15 -3
- package/dist/store/store.js +28 -7
- package/dist/telemetry/sinks/otlp.d.ts +31 -0
- package/dist/telemetry/sinks/otlp.js +76 -0
- package/dist/tools/builtins/filesystem.js +1 -0
- package/dist/tools/builtins/git.js +1 -0
- package/dist/tools/builtins/shell.js +1 -0
- package/dist/tools/permissions.d.ts +28 -0
- package/dist/tools/permissions.js +72 -0
- package/dist/tools/registry.d.ts +18 -2
- package/dist/tools/registry.js +22 -2
- package/dist/tools/tool.d.ts +4 -0
- package/dist/types.d.ts +5 -1
- package/dist/util/flatten.d.ts +11 -0
- package/dist/util/flatten.js +18 -0
- package/dist/util/hash.d.ts +19 -0
- package/dist/util/hash.js +39 -0
- package/dist/util/semaphore.d.ts +19 -0
- package/dist/util/semaphore.js +60 -0
- package/package.json +24 -9
|
@@ -0,0 +1,377 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The agent worker (Phase 3.4) - runs ONE agent step.
|
|
3
|
+
*
|
|
4
|
+
* IT NEVER CALLS `orchestrate()`. It composes `generatePlan` + `executePlan` directly, because
|
|
5
|
+
* re-entering the orchestrator inside a step would re-run the parent's gates per step: re-asking for
|
|
6
|
+
* approval, re-applying the parent call budget, and re-checking plan/dry-run mode. A useful consequence
|
|
7
|
+
* of that same structure is that a dry run can never reach a worker at all - `orchestrate` returns
|
|
8
|
+
* before `executePlan` is ever called.
|
|
9
|
+
*
|
|
10
|
+
* IT NEVER CONSTRUCTS AN `AI`. One router: the inner planning call goes through a METERED FACADE over
|
|
11
|
+
* the parent's AI, so every inner model call is counted against the envelope's reservation - including
|
|
12
|
+
* the ones a skill makes through `SkillContext.ai`, which the parent budget cannot see today.
|
|
13
|
+
*
|
|
14
|
+
* DEPTH 1 IS STRUCTURAL: the inner plan is generated with `agents: []`, so `validatePlan` rejects a
|
|
15
|
+
* nested agent step as `unknown agent`. There is no runtime depth counter to get wrong.
|
|
16
|
+
*/
|
|
17
|
+
import { generatePlan } from '../orchestration/planner.js';
|
|
18
|
+
import { executePlan } from '../orchestration/executor.js';
|
|
19
|
+
import { wrapUntrusted } from '../tools/untrusted.js';
|
|
20
|
+
import { flattenClamp } from '../util/flatten.js';
|
|
21
|
+
import { admitFindings, contractFailed } from './admit.js';
|
|
22
|
+
import { executionCoverage } from './finding.js';
|
|
23
|
+
import { AGENT_TASK_PROJECTION, nextAgentTaskId } from './task.js';
|
|
24
|
+
import { hashOf } from '../util/hash.js';
|
|
25
|
+
import { validatePlan } from '../orchestration/plan.js';
|
|
26
|
+
/** How much of a step input may reach the inner prompt. It is model-authored, and it is fenced. */
|
|
27
|
+
export const AGENT_INPUT_MAX = 1000;
|
|
28
|
+
/** Inner observations persisted per task. The record lives in a JSON file that is rewritten every
|
|
29
|
+
* commit, so this is a durability bound, not a display one. Beyond it, the count is kept and the
|
|
30
|
+
* content dropped — an honest "there was more" rather than a silently short list. */
|
|
31
|
+
export const INNER_OBS_MAX = 20;
|
|
32
|
+
/** Admission rejections kept per task. They accumulate across attempts and are rewritten every commit. */
|
|
33
|
+
export const DIAGNOSTICS_KEPT = 50;
|
|
34
|
+
/**
|
|
35
|
+
* What makes a step THIS step. Plan step ids (`s1`, `auto1`) are model-authored and recur across
|
|
36
|
+
* replans, so binding a persisted record by id alone would hand one step's completed inner work to a
|
|
37
|
+
* different step that happens to share its id — same agent, different input, silently wrong findings.
|
|
38
|
+
*/
|
|
39
|
+
export function stepIdentity(step) {
|
|
40
|
+
return hashOf({ agent: step.agent, description: step.description, input: step.input ?? null });
|
|
41
|
+
}
|
|
42
|
+
/** Raised by the metered facade when an agent tries to exceed its reservation. */
|
|
43
|
+
class AgentCallBudgetError extends Error {
|
|
44
|
+
constructor() {
|
|
45
|
+
super('agent inner model-call reservation exhausted');
|
|
46
|
+
this.name = 'AgentCallBudgetError';
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
export async function runAgentTask(step, envelope, definition, deps) {
|
|
50
|
+
const now = deps.clock.now();
|
|
51
|
+
// Computed from what is TRUE NOW, so a persisted record can be compared against the run it is about
|
|
52
|
+
// to be reused in. Reading them off the resumed record instead would compare a value with itself.
|
|
53
|
+
const defHash = hashOf(definition);
|
|
54
|
+
const envHash = hashOf(envelope);
|
|
55
|
+
const record = deps.resume ?? {
|
|
56
|
+
v: 1,
|
|
57
|
+
agentTaskId: nextAgentTaskId(now),
|
|
58
|
+
agentId: envelope.agentId,
|
|
59
|
+
stepId: step.id,
|
|
60
|
+
state: 'created',
|
|
61
|
+
createdAt: now,
|
|
62
|
+
updatedAt: now,
|
|
63
|
+
provenance: deps.provenance,
|
|
64
|
+
// The three identity hashes. They are computed HERE, at the only place a task is minted, so a
|
|
65
|
+
// resumed record can be compared against the run it is about to be reused in (Phase 3.5).
|
|
66
|
+
agentDefHash: defHash,
|
|
67
|
+
envelopeHash: envHash,
|
|
68
|
+
stepInputHash: stepIdentity(step),
|
|
69
|
+
attempt: 1,
|
|
70
|
+
innerCompletedSteps: [],
|
|
71
|
+
innerObservations: [],
|
|
72
|
+
innerObservationsOmitted: 0,
|
|
73
|
+
innerSteps: { total: 0, succeeded: 0 },
|
|
74
|
+
callsReserved: envelope.reservation,
|
|
75
|
+
callsUsed: 0,
|
|
76
|
+
callsRefunded: 0,
|
|
77
|
+
toolCallsUsed: 0,
|
|
78
|
+
findings: [],
|
|
79
|
+
diagnostics: [],
|
|
80
|
+
};
|
|
81
|
+
if (deps.resume) {
|
|
82
|
+
// A resumed record starts a NEW attempt: findings it mints are namespaced by the attempt number, so
|
|
83
|
+
// they can never collide with findings already on the record from the attempt that was interrupted.
|
|
84
|
+
record.attempt += 1;
|
|
85
|
+
// The stamp must describe the LATEST interruption, so it is cleared on promotion, not left to age.
|
|
86
|
+
delete record.interruption;
|
|
87
|
+
// The question has been answered (or the task is being retried); either way it is no longer pending,
|
|
88
|
+
// and leaving it set would re-elect this task as the run's wait on the very next commit.
|
|
89
|
+
delete record.pendingInner;
|
|
90
|
+
}
|
|
91
|
+
record.state = 'queued';
|
|
92
|
+
record.updatedAt = deps.clock.now();
|
|
93
|
+
deps.onRecord?.(record);
|
|
94
|
+
// (2) A child controller, so a parent abort reaches the inner run and nothing else does.
|
|
95
|
+
const child = new AbortController();
|
|
96
|
+
const onParentAbort = () => child.abort();
|
|
97
|
+
if (deps.parentSignal?.aborted)
|
|
98
|
+
child.abort();
|
|
99
|
+
else
|
|
100
|
+
deps.parentSignal?.addEventListener('abort', onParentAbort, { once: true });
|
|
101
|
+
const deadline = deps.clock.now() + envelope.maxDurationMs;
|
|
102
|
+
const expired = () => deps.clock.now() > deadline;
|
|
103
|
+
// (3) THE METERED FACADE. Prototype-delegating, so it IS an AI (assignable to SkillContext.ai) while
|
|
104
|
+
// owning `run`. The count increments BEFORE the await, so a failed inner call is still charged.
|
|
105
|
+
// Prior spend carries across a resume: the reservation is the ceiling for the TASK, not per attempt,
|
|
106
|
+
// so a task that already burned 2 of 3 calls before a crash gets 1 more, not 3.
|
|
107
|
+
const priorCalls = deps.resume?.callsUsed ?? 0;
|
|
108
|
+
const priorToolCalls = deps.resume?.toolCallsUsed ?? 0;
|
|
109
|
+
let calls = 0;
|
|
110
|
+
const spent = () => priorCalls + calls;
|
|
111
|
+
const metered = Object.create(deps.ai);
|
|
112
|
+
metered.run = async (req) => {
|
|
113
|
+
if (spent() >= envelope.reservation)
|
|
114
|
+
throw new AgentCallBudgetError();
|
|
115
|
+
calls += 1;
|
|
116
|
+
return deps.ai.run(req);
|
|
117
|
+
};
|
|
118
|
+
let toolCalls = 0;
|
|
119
|
+
const toolResults = [];
|
|
120
|
+
const skillResults = [];
|
|
121
|
+
const finish = (state, failure) => {
|
|
122
|
+
deps.parentSignal?.removeEventListener('abort', onParentAbort);
|
|
123
|
+
record.state = state;
|
|
124
|
+
record.endedAt = deps.clock.now();
|
|
125
|
+
record.updatedAt = record.endedAt;
|
|
126
|
+
record.callsUsed = priorCalls + calls;
|
|
127
|
+
record.toolCallsUsed = priorToolCalls + toolCalls;
|
|
128
|
+
// Derived, for reporting only. The real refund is that the executor charges what was SPENT, so the
|
|
129
|
+
// unspent part of the reservation is simply never charged — there is no ledger to get wrong.
|
|
130
|
+
record.callsRefunded = Math.max(0, record.callsReserved - record.callsUsed);
|
|
131
|
+
if (failure)
|
|
132
|
+
record.failure = failure;
|
|
133
|
+
const row = AGENT_TASK_PROJECTION[state];
|
|
134
|
+
const observation = {
|
|
135
|
+
stepId: step.id,
|
|
136
|
+
agent: envelope.agentId,
|
|
137
|
+
agentTaskId: record.agentTaskId,
|
|
138
|
+
ok: state === 'completed',
|
|
139
|
+
callsUsed: calls,
|
|
140
|
+
...(failure ? { code: failure.code, error: failure.message } : row.code ? { code: row.code } : {}),
|
|
141
|
+
...(record.findings.length ? { data: { findings: record.findings } } : {}),
|
|
142
|
+
...(record.findings.some((f) => f.artifacts.length) ? { artifacts: record.findings.flatMap((f) => f.artifacts) } : {}),
|
|
143
|
+
...(state === 'completed' ? { output: `${envelope.agentId}: ${record.innerSteps.succeeded}/${record.innerSteps.total} inner step(s), ${record.findings.length} finding(s)` } : {}),
|
|
144
|
+
};
|
|
145
|
+
deps.emit?.({ type: 'agent.task.completed', record });
|
|
146
|
+
deps.onRecord?.(record);
|
|
147
|
+
return { observation, record };
|
|
148
|
+
};
|
|
149
|
+
// (12) A parent abort is a cancel or a pause; both project to the same observation, and the record
|
|
150
|
+
// carries which so a later phase can re-queue a paused task rather than treat it as cancelled.
|
|
151
|
+
const abortedNow = () => {
|
|
152
|
+
if (!child.signal.aborted)
|
|
153
|
+
return undefined;
|
|
154
|
+
const kind = deps.abortReason?.() ?? 'parent-cancel';
|
|
155
|
+
if (kind === 'pause') {
|
|
156
|
+
record.interruption = { kind: 'pause', at: deps.clock.now() };
|
|
157
|
+
return finish('queued', { code: 'cancelled', message: 'cancelled: the run was paused' });
|
|
158
|
+
}
|
|
159
|
+
record.interruption = { kind: 'parent-cancel', at: deps.clock.now() };
|
|
160
|
+
return finish('cancelled', { code: 'cancelled', message: 'cancelled: the run was aborted' });
|
|
161
|
+
};
|
|
162
|
+
const preempted = abortedNow();
|
|
163
|
+
if (preempted)
|
|
164
|
+
return preempted;
|
|
165
|
+
record.state = 'running';
|
|
166
|
+
record.startedAt = deps.clock.now();
|
|
167
|
+
record.updatedAt = record.startedAt;
|
|
168
|
+
deps.emit?.({ type: 'agent.task.started', record });
|
|
169
|
+
deps.onRecord?.(record);
|
|
170
|
+
// (6) THE TOOL SEAM - defense in depth behind narrowEnvelope. Always a structured denial, never a
|
|
171
|
+
// throw: the tool contract is that a refusal is visible.
|
|
172
|
+
const innerCallTool = async (id, input) => {
|
|
173
|
+
if (!envelope.tools.includes(id))
|
|
174
|
+
return { ok: false, error: { code: 'PERMISSION', message: `'${flattenClamp(id, 40)}' is not in this agent's catalog` } };
|
|
175
|
+
if (priorToolCalls + toolCalls + 1 > envelope.maxToolCalls)
|
|
176
|
+
return { ok: false, error: { code: 'PERMISSION', message: `agent tool-call budget exhausted (${envelope.maxToolCalls})` } };
|
|
177
|
+
if (expired())
|
|
178
|
+
return { ok: false, error: { code: 'TIMEOUT', message: 'agent time budget exhausted' } };
|
|
179
|
+
toolCalls += 1;
|
|
180
|
+
const result = await deps.runTool(id, input, { permissions: envelope.permissions, signal: child.signal });
|
|
181
|
+
return result;
|
|
182
|
+
};
|
|
183
|
+
// (4) PROMPT BOUNDARY #1: the objective is definition-authored and clamped; the step input is
|
|
184
|
+
// MODEL-authored and therefore fenced as untrusted data.
|
|
185
|
+
const innerGoal = [
|
|
186
|
+
envelope.objective,
|
|
187
|
+
wrapUntrusted(`agent-input:${envelope.agentId}`, flattenClamp(JSON.stringify(step.input ?? null), AGENT_INPUT_MAX)),
|
|
188
|
+
// The answer to THIS task's own question, routed here rather than appended to the outer goal —
|
|
189
|
+
// appending it there would replan the outer plan and discard every sibling agent's work.
|
|
190
|
+
...(deps.resumeAnswer ? [wrapUntrusted(`agent-answer:${envelope.agentId}`, flattenClamp(deps.resumeAnswer, AGENT_INPUT_MAX))] : []),
|
|
191
|
+
].join('\n\n');
|
|
192
|
+
// A persisted inner plan is REUSED rather than regenerated — that is what makes a resume cheap and
|
|
193
|
+
// what stops completed inner work being redone. It is still re-validated against the envelope's
|
|
194
|
+
// catalog first: the plan came off disk, and `validatePlan` is the membership gate for every plan,
|
|
195
|
+
// resumed or fresh. A plan that no longer validates is discarded, not repaired.
|
|
196
|
+
let innerPlan;
|
|
197
|
+
const identical = deps.resume ? deps.resume.envelopeHash === envHash && deps.resume.agentDefHash === defHash : false;
|
|
198
|
+
// An ANSWER changes what the plan should be — that is the entire point of having asked — so a task
|
|
199
|
+
// being resumed with one always re-plans rather than replaying the plan made before the question.
|
|
200
|
+
if (deps.resume?.innerPlan && identical && !deps.resumeAnswer) {
|
|
201
|
+
const check = validatePlan(deps.resume.innerPlan, { skills: envelope.skills, tools: envelope.tools, agents: [] });
|
|
202
|
+
if (check.ok)
|
|
203
|
+
innerPlan = deps.resume.innerPlan;
|
|
204
|
+
}
|
|
205
|
+
// The record now belongs to THIS run: an edited definition or a re-narrowed envelope invalidates the
|
|
206
|
+
// old plan (handled above) but not the task, so the hashes move forward with it.
|
|
207
|
+
record.agentDefHash = defHash;
|
|
208
|
+
record.envelopeHash = envHash;
|
|
209
|
+
if (!innerPlan) {
|
|
210
|
+
let planned;
|
|
211
|
+
try {
|
|
212
|
+
planned = await generatePlan({ goal: innerGoal, ai: metered, skills: deps.skills, tools: envelope.tools, agents: [], version: 1, ...(envelope.routing ? { routing: envelope.routing } : {}) });
|
|
213
|
+
}
|
|
214
|
+
catch (err) {
|
|
215
|
+
if (err instanceof AgentCallBudgetError)
|
|
216
|
+
return finish('failed', { code: 'agent-call-budget', message: err.message });
|
|
217
|
+
return finish('failed', { code: 'agent-failed', message: flattenClamp(err instanceof Error ? err.message : String(err), 160) });
|
|
218
|
+
}
|
|
219
|
+
if (planned.clarification) {
|
|
220
|
+
// The one inner wait that exists: the planner needs an answer. The task stays RESUMABLE and the
|
|
221
|
+
// step stays pending — this is not a failure, and it must not fail the branch.
|
|
222
|
+
record.pendingInner = { kind: 'clarification', question: flattenClamp(planned.clarification, 240), at: deps.clock.now() };
|
|
223
|
+
return finish('waiting_for_clarification', { code: 'agent-waiting', message: record.pendingInner.question });
|
|
224
|
+
}
|
|
225
|
+
if (!planned.plan) {
|
|
226
|
+
return finish('failed', { code: 'agent-failed', message: flattenClamp(planned.error ?? 'the agent could not form a plan', 160) });
|
|
227
|
+
}
|
|
228
|
+
innerPlan = planned.plan;
|
|
229
|
+
// A REGENERATED plan has new step ids. Carrying the discarded plan's completed ids into its skip
|
|
230
|
+
// set would mark steps `succeeded` that never ran — the dangerous direction of a resume, since the
|
|
231
|
+
// work is silently not done rather than merely done twice.
|
|
232
|
+
record.innerCompletedSteps = [];
|
|
233
|
+
record.innerObservations = [];
|
|
234
|
+
record.innerObservationsOmitted = 0;
|
|
235
|
+
}
|
|
236
|
+
record.innerPlan = innerPlan;
|
|
237
|
+
record.innerPlanVersion = innerPlan.version;
|
|
238
|
+
// The plan exists and nothing has run: a crash from here on resumes against a real inner plan.
|
|
239
|
+
record.updatedAt = deps.clock.now();
|
|
240
|
+
deps.onRecord?.(record);
|
|
241
|
+
const aborted = abortedNow();
|
|
242
|
+
if (aborted)
|
|
243
|
+
return aborted;
|
|
244
|
+
if (expired())
|
|
245
|
+
return finish('failed', { code: 'agent-timeout', message: `agent exceeded ${envelope.maxDurationMs}ms` });
|
|
246
|
+
// (5) Inner execution. NO callBudget: the facade in (3) is a strictly tighter ceiling and also covers
|
|
247
|
+
// a skill's own model calls, which a wave gate cannot see.
|
|
248
|
+
let inner;
|
|
249
|
+
try {
|
|
250
|
+
inner = await executePlan(innerPlan, {
|
|
251
|
+
skip: new Set(record.innerCompletedSteps),
|
|
252
|
+
// THE seam that makes inner work survive a crash. Everything between `running` and `finish()` was
|
|
253
|
+
// otherwise invisible to disk, so a resumed task had nothing to skip and re-ran the whole inner plan.
|
|
254
|
+
onProgress: (snap) => {
|
|
255
|
+
record.innerPlan = snap.plan;
|
|
256
|
+
record.innerCompletedSteps = snap.plan.steps.filter((st) => st.status === 'succeeded').map((st) => st.id);
|
|
257
|
+
const room = INNER_OBS_MAX - record.innerObservations.length;
|
|
258
|
+
if (room > 0)
|
|
259
|
+
record.innerObservations.push(...snap.observations.slice(0, room));
|
|
260
|
+
record.innerObservationsOmitted += Math.max(0, snap.observations.length - Math.max(0, room));
|
|
261
|
+
record.callsUsed = priorCalls + calls;
|
|
262
|
+
record.toolCallsUsed = priorToolCalls + toolCalls;
|
|
263
|
+
record.updatedAt = deps.clock.now();
|
|
264
|
+
deps.onRecord?.(record);
|
|
265
|
+
},
|
|
266
|
+
runSkill: async (id, i) => {
|
|
267
|
+
const out = await deps.runSkill(id, i, { permissions: envelope.permissions, signal: child.signal, ai: metered });
|
|
268
|
+
skillResults.push({ stepId: id, result: out.result, validation: out.validation });
|
|
269
|
+
return out;
|
|
270
|
+
},
|
|
271
|
+
runTool: async (id, i) => {
|
|
272
|
+
const result = await innerCallTool(id, i);
|
|
273
|
+
toolResults.push({ stepId: id, result });
|
|
274
|
+
return result;
|
|
275
|
+
},
|
|
276
|
+
maxParallelSteps: 1,
|
|
277
|
+
signal: child.signal,
|
|
278
|
+
});
|
|
279
|
+
}
|
|
280
|
+
catch (err) {
|
|
281
|
+
if (err instanceof AgentCallBudgetError)
|
|
282
|
+
return finish('failed', { code: 'agent-call-budget', message: err.message });
|
|
283
|
+
return finish('failed', { code: 'agent-failed', message: flattenClamp(err instanceof Error ? err.message : String(err), 160) });
|
|
284
|
+
}
|
|
285
|
+
record.innerSteps = { total: inner.plan.steps.length, succeeded: inner.plan.steps.filter((s) => s.status === 'succeeded').length };
|
|
286
|
+
record.innerCompletedSteps = inner.plan.steps.filter((s) => s.status === 'succeeded').map((s) => s.id);
|
|
287
|
+
const afterRun = abortedNow();
|
|
288
|
+
if (afterRun)
|
|
289
|
+
return afterRun;
|
|
290
|
+
// (8) Deterministic coverage, from statuses only.
|
|
291
|
+
const coverage = executionCoverage(inner.plan.steps);
|
|
292
|
+
// (10) Evidence: one entry per inner step, keyed STRUCTURALLY by what the step produced. A skill's
|
|
293
|
+
// validation checks are free-form names, so there is nothing to parse - the kind comes from the fact
|
|
294
|
+
// that a SkillValidation exists at all.
|
|
295
|
+
const evidence = [
|
|
296
|
+
...skillResults.map((s) => ({
|
|
297
|
+
kind: 'validation',
|
|
298
|
+
stepId: s.stepId,
|
|
299
|
+
detail: flattenClamp(s.result.output ?? s.result.error?.message ?? 'skill completed', 160),
|
|
300
|
+
ok: s.result.ok && s.validation.ok,
|
|
301
|
+
checks: { passed: s.validation.checks.filter((c) => c.ok).length, total: s.validation.checks.length },
|
|
302
|
+
})),
|
|
303
|
+
...toolResults.map((t) => ({
|
|
304
|
+
kind: 'tool-output',
|
|
305
|
+
stepId: t.stepId,
|
|
306
|
+
detail: flattenClamp(t.result.output ?? t.result.error?.message ?? 'tool completed', 160),
|
|
307
|
+
ok: t.result.ok,
|
|
308
|
+
})),
|
|
309
|
+
];
|
|
310
|
+
// (11) HEADLESS DENIAL => FINDING + FAIL-BRANCH. There is no inner approval wait at 3.0: a denial is
|
|
311
|
+
// reported as a first-class finding and the branch fails. The worker can see `error.code` because it
|
|
312
|
+
// holds the ToolResult directly; the executor flattens it away.
|
|
313
|
+
const denials = toolResults
|
|
314
|
+
.filter((t) => !t.result.ok && t.result.error?.code === 'PERMISSION')
|
|
315
|
+
.map((t, i) => ({
|
|
316
|
+
id: `${record.agentTaskId}_d${i + 1}`,
|
|
317
|
+
agentId: envelope.agentId,
|
|
318
|
+
agentTaskId: record.agentTaskId,
|
|
319
|
+
type: 'permission-denied',
|
|
320
|
+
subject: flattenClamp(t.stepId, 80),
|
|
321
|
+
claim: flattenClamp(`the agent was denied a permitted action: ${t.result.error?.message ?? 'permission denied'}`, 240),
|
|
322
|
+
executionCoverage: coverage,
|
|
323
|
+
confidence: 0.5,
|
|
324
|
+
evidence: [{ kind: 'tool-output', stepId: t.stepId, detail: flattenClamp(t.result.error?.message ?? 'permission denied', 160), ok: false }],
|
|
325
|
+
artifacts: [],
|
|
326
|
+
sourceSteps: [t.stepId],
|
|
327
|
+
provenance: { ...deps.provenance },
|
|
328
|
+
status: 'active',
|
|
329
|
+
createdAt: deps.clock.now(),
|
|
330
|
+
}));
|
|
331
|
+
// (9) Candidates come from the FINAL inner step's structured data - never from model prose.
|
|
332
|
+
const last = [...skillResults].pop();
|
|
333
|
+
const lastTool = [...toolResults].pop();
|
|
334
|
+
const finalData = (last?.result.data ?? lastTool?.result.data);
|
|
335
|
+
const admission = admitFindings({
|
|
336
|
+
agentTaskId: record.agentTaskId,
|
|
337
|
+
agentId: envelope.agentId,
|
|
338
|
+
candidates: finalData?.findings ?? [],
|
|
339
|
+
...(definition.outputContract ? { contract: definition.outputContract } : {}),
|
|
340
|
+
coverage,
|
|
341
|
+
...(finalData?.confidence !== undefined ? { confidenceOverride: finalData.confidence } : {}),
|
|
342
|
+
evidenceFor: () => evidence,
|
|
343
|
+
artifactsFor: (index) => {
|
|
344
|
+
const source = `agent:${envelope.agentId}#${index}`;
|
|
345
|
+
const content = evidence.map((e) => `${e.kind} ${e.stepId}: ${e.detail}`).join('\n');
|
|
346
|
+
if (!content)
|
|
347
|
+
return { refs: [], unavailable: false };
|
|
348
|
+
const put = deps.putArtifact(content, source);
|
|
349
|
+
return put.ref ? { refs: [put.ref], unavailable: false } : { refs: [], unavailable: true };
|
|
350
|
+
},
|
|
351
|
+
sourceSteps: inner.plan.steps.map((s) => s.id),
|
|
352
|
+
provenance: deps.provenance,
|
|
353
|
+
now: deps.clock.now(),
|
|
354
|
+
});
|
|
355
|
+
// MERGE, never assign. A resumed attempt that admits fewer findings than the interrupted one would
|
|
356
|
+
// otherwise drop the earlier ones on the floor — and observations already committed to disk reference
|
|
357
|
+
// them by id. Attempt >1 namespaces its ids so the two attempts can never collide.
|
|
358
|
+
const mint = (f) => (record.attempt > 1 ? { ...f, id: `${f.id}@a${record.attempt}` } : f);
|
|
359
|
+
const fresh = [...denials, ...admission.admitted].map(mint);
|
|
360
|
+
const seenFinding = new Set(record.findings.map((f) => f.id));
|
|
361
|
+
record.findings = [...record.findings, ...fresh.filter((f) => !seenFinding.has(f.id))];
|
|
362
|
+
// Bounded: diagnostics accumulate across attempts, and every one of them is rewritten to disk on
|
|
363
|
+
// every commit. Keeping the most recent is the useful half.
|
|
364
|
+
record.diagnostics = [...record.diagnostics, ...admission.rejected].slice(-DIAGNOSTICS_KEPT);
|
|
365
|
+
if (contractFailed(admission)) {
|
|
366
|
+
return finish('failed', { code: 'finding-contract', message: 'the agent did not satisfy its declared output contract' });
|
|
367
|
+
}
|
|
368
|
+
if (expired())
|
|
369
|
+
return finish('failed', { code: 'agent-timeout', message: `agent exceeded ${envelope.maxDurationMs}ms` });
|
|
370
|
+
if (toolCalls > envelope.maxToolCalls)
|
|
371
|
+
return finish('failed', { code: 'agent-tool-budget', message: `agent exceeded ${envelope.maxToolCalls} tool call(s)` });
|
|
372
|
+
if (!inner.ok) {
|
|
373
|
+
const why = inner.observations.filter((o) => !o.ok).map((o) => `${o.stepId}: ${o.error ?? 'failed'}`).join('; ');
|
|
374
|
+
return finish('failed', { code: 'agent-failed', message: flattenClamp(why || 'the agent plan did not complete', 160) });
|
|
375
|
+
}
|
|
376
|
+
return finish('completed');
|
|
377
|
+
}
|
|
@@ -0,0 +1,117 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACTION capabilities (Phase 3.1) — what the system can DO (`read_file`, `run_tests`, `read_issue`),
|
|
3
|
+
* independent of which tool / skill / MCP server provides it. Deliberately distinct from the router's
|
|
4
|
+
* MODEL-capability system (`src/core/capabilities/**`, `CapabilityProfile`/`CapabilityRequirement`):
|
|
5
|
+
* every type here is `Action*`-prefixed, nothing under `src/core/` imports this module, and the router
|
|
6
|
+
* never sees an action capability.
|
|
7
|
+
*
|
|
8
|
+
* SECURITY: `effects` is advisory metadata only and NEVER authorization. It orders resolution, drives
|
|
9
|
+
* permission-gap suggestions, and labels the catalog; the actual operation permission is always enforced
|
|
10
|
+
* by the selected Tool/Skill execution path (`runTool`/`runSkill` → `ToolContext.permissions`).
|
|
11
|
+
*/
|
|
12
|
+
import type { Evidence } from '../types.js';
|
|
13
|
+
/** Side-effect class. Two values only: anything not provably read-only is `write` (fail-closed). */
|
|
14
|
+
export type ActionCapabilityEffect = 'read' | 'write';
|
|
15
|
+
/** Where a provider came from. `plugin` is reserved for a future plugin system. */
|
|
16
|
+
export type ActionCapabilitySource = 'native' | 'skill' | 'mcp' | 'openapi' | 'plugin';
|
|
17
|
+
/** Whether a provider can be used right now. `disabled` is operator-driven (MCP, Phase 3.2). */
|
|
18
|
+
export type ProviderAvailability = 'available' | 'unavailable' | 'disabled' | 'unknown';
|
|
19
|
+
/** What a tool / skill / manifest declares it provides. Effects absent ⇒ ['write'] (fail-closed). */
|
|
20
|
+
export interface ActionCapabilityDecl {
|
|
21
|
+
id: string;
|
|
22
|
+
effects?: ActionCapabilityEffect[];
|
|
23
|
+
description?: string;
|
|
24
|
+
}
|
|
25
|
+
/** A capability entity — a DERIVED view keyed by id (curated metadata wins; see vocabulary.ts). */
|
|
26
|
+
export interface ActionCapability {
|
|
27
|
+
id: string;
|
|
28
|
+
name: string;
|
|
29
|
+
description: string;
|
|
30
|
+
effects: ActionCapabilityEffect[];
|
|
31
|
+
/** True when the id belongs to the shipped curated vocabulary. */
|
|
32
|
+
curated: boolean;
|
|
33
|
+
}
|
|
34
|
+
/** One concrete provider of one capability. Bindings always execute through an EXISTING registry. */
|
|
35
|
+
export interface ActionCapabilityProvider {
|
|
36
|
+
capabilityId: string;
|
|
37
|
+
/** `tool:<id>` | `skill:<id>` | `mcp:<server>.<tool>` — the one grammar pins/aliases/tie-breaks use. */
|
|
38
|
+
providerId: string;
|
|
39
|
+
source: ActionCapabilitySource;
|
|
40
|
+
binding: {
|
|
41
|
+
kind: 'tool';
|
|
42
|
+
toolId: string;
|
|
43
|
+
} | {
|
|
44
|
+
kind: 'skill';
|
|
45
|
+
skillId: string;
|
|
46
|
+
};
|
|
47
|
+
effects: ActionCapabilityEffect[];
|
|
48
|
+
/** `inferred` (undeclared), `documented` (declared or config-aliased), `verified` (future probes). */
|
|
49
|
+
verification: Evidence;
|
|
50
|
+
availability: ProviderAvailability;
|
|
51
|
+
note?: string;
|
|
52
|
+
}
|
|
53
|
+
/** Why a required capability could not be satisfied. */
|
|
54
|
+
export type CapabilityGapReason = 'unknown' | 'unavailable' | 'disabled' | 'permission';
|
|
55
|
+
export type GapSuggestion = {
|
|
56
|
+
kind: 'register';
|
|
57
|
+
hint: string;
|
|
58
|
+
} | {
|
|
59
|
+
kind: 'enable-server';
|
|
60
|
+
serverId: string;
|
|
61
|
+
} | {
|
|
62
|
+
kind: 'install-skill';
|
|
63
|
+
hint: string;
|
|
64
|
+
} | {
|
|
65
|
+
kind: 'grant-permission';
|
|
66
|
+
policyKey: string;
|
|
67
|
+
};
|
|
68
|
+
export interface CapabilityGap {
|
|
69
|
+
capabilityId: string;
|
|
70
|
+
reason: CapabilityGapReason;
|
|
71
|
+
/** Every known provider (may be unavailable/disabled/denied); empty ⇒ reason 'unknown'. */
|
|
72
|
+
providers: ActionCapabilityProvider[];
|
|
73
|
+
suggestions: GapSuggestion[];
|
|
74
|
+
}
|
|
75
|
+
/** The user-facing report: what a goal needed, what is available, and what to do about the rest. */
|
|
76
|
+
export interface MissingCapabilityReport {
|
|
77
|
+
required: Array<{
|
|
78
|
+
capabilityId: string;
|
|
79
|
+
status: 'available' | CapabilityGapReason;
|
|
80
|
+
providers: string[];
|
|
81
|
+
}>;
|
|
82
|
+
gaps: CapabilityGap[];
|
|
83
|
+
suggestions: GapSuggestion[];
|
|
84
|
+
}
|
|
85
|
+
export interface CapabilityResolution {
|
|
86
|
+
ok: boolean;
|
|
87
|
+
satisfied: Array<{
|
|
88
|
+
capabilityId: string;
|
|
89
|
+
chosen: ActionCapabilityProvider;
|
|
90
|
+
alternates: ActionCapabilityProvider[];
|
|
91
|
+
}>;
|
|
92
|
+
gaps: CapabilityGap[];
|
|
93
|
+
}
|
|
94
|
+
/** A bare curated id: `^[a-z][a-z0-9_]*$`. */
|
|
95
|
+
export declare const CURATED_ID_RE: RegExp;
|
|
96
|
+
/** A namespaced id: `tool:`/`skill:`/`mcp:` + a segment containing no further colon. */
|
|
97
|
+
export declare const NAMESPACED_ID_RE: RegExp;
|
|
98
|
+
/** Sanitize an arbitrary source id into a namespace segment (never rejects — existing callers keep working). */
|
|
99
|
+
export declare function sanitizeSegment(raw: string): string;
|
|
100
|
+
/** Build the namespaced id for a source-owned capability: `tool:filesystem`, `skill:code-review`. */
|
|
101
|
+
export declare function namespacedId(kind: 'tool' | 'skill' | 'mcp', segment: string): string;
|
|
102
|
+
/**
|
|
103
|
+
* Resolve the id a declaration actually registers under (the claiming rule, D2). A declared bare id is
|
|
104
|
+
* kept ONLY when the declaration is trusted (in-tree builtins) and the id is curated; a declared
|
|
105
|
+
* NAMESPACED id is kept only when it is the declarer's OWN id (`tool:<own-segment>`) — otherwise the
|
|
106
|
+
* declaration is namespaced to its source, so no source can ever claim a curated name or impersonate
|
|
107
|
+
* another provider's namespaced capability.
|
|
108
|
+
*/
|
|
109
|
+
export declare function effectiveDeclId(decl: ActionCapabilityDecl, kind: 'tool' | 'skill' | 'mcp', segment: string, trusted: boolean): string;
|
|
110
|
+
/** Effects for a declaration: declared values, else fail-closed `['write']`. Curated ids use vocabulary. */
|
|
111
|
+
export declare function effectsOf(decl: ActionCapabilityDecl | undefined, id: string): ActionCapabilityEffect[];
|
|
112
|
+
/** Build the derived entity view for an id (curated metadata wins; else the declaring source's text). */
|
|
113
|
+
export declare function entityFor(id: string, fallback: {
|
|
114
|
+
name?: string;
|
|
115
|
+
description?: string;
|
|
116
|
+
effects?: ActionCapabilityEffect[];
|
|
117
|
+
}): ActionCapability;
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* ACTION capabilities (Phase 3.1) — what the system can DO (`read_file`, `run_tests`, `read_issue`),
|
|
3
|
+
* independent of which tool / skill / MCP server provides it. Deliberately distinct from the router's
|
|
4
|
+
* MODEL-capability system (`src/core/capabilities/**`, `CapabilityProfile`/`CapabilityRequirement`):
|
|
5
|
+
* every type here is `Action*`-prefixed, nothing under `src/core/` imports this module, and the router
|
|
6
|
+
* never sees an action capability.
|
|
7
|
+
*
|
|
8
|
+
* SECURITY: `effects` is advisory metadata only and NEVER authorization. It orders resolution, drives
|
|
9
|
+
* permission-gap suggestions, and labels the catalog; the actual operation permission is always enforced
|
|
10
|
+
* by the selected Tool/Skill execution path (`runTool`/`runSkill` → `ToolContext.permissions`).
|
|
11
|
+
*/
|
|
12
|
+
import { curated, isCurated } from './vocabulary.js';
|
|
13
|
+
// ── id grammar (D2/D3) ───────────────────────────────────────────────────────
|
|
14
|
+
/** A bare curated id: `^[a-z][a-z0-9_]*$`. */
|
|
15
|
+
export const CURATED_ID_RE = /^[a-z][a-z0-9_]*$/;
|
|
16
|
+
/** A namespaced id: `tool:`/`skill:`/`mcp:` + a segment containing no further colon. */
|
|
17
|
+
export const NAMESPACED_ID_RE = /^(tool|skill|mcp):[^:\s][^:]*$/;
|
|
18
|
+
/** Sanitize an arbitrary source id into a namespace segment (never rejects — existing callers keep working). */
|
|
19
|
+
export function sanitizeSegment(raw) {
|
|
20
|
+
const s = String(raw).trim().toLowerCase().replace(/:/g, '-').replace(/\s+/g, '-').replace(/[^a-z0-9._/-]/g, '');
|
|
21
|
+
return s || 'unnamed';
|
|
22
|
+
}
|
|
23
|
+
/** Build the namespaced id for a source-owned capability: `tool:filesystem`, `skill:code-review`. */
|
|
24
|
+
export function namespacedId(kind, segment) {
|
|
25
|
+
return `${kind}:${sanitizeSegment(segment)}`;
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Resolve the id a declaration actually registers under (the claiming rule, D2). A declared bare id is
|
|
29
|
+
* kept ONLY when the declaration is trusted (in-tree builtins) and the id is curated; a declared
|
|
30
|
+
* NAMESPACED id is kept only when it is the declarer's OWN id (`tool:<own-segment>`) — otherwise the
|
|
31
|
+
* declaration is namespaced to its source, so no source can ever claim a curated name or impersonate
|
|
32
|
+
* another provider's namespaced capability.
|
|
33
|
+
*/
|
|
34
|
+
export function effectiveDeclId(decl, kind, segment, trusted) {
|
|
35
|
+
const id = String(decl.id ?? '').trim();
|
|
36
|
+
const own = namespacedId(kind, segment);
|
|
37
|
+
// The declarer's own id, or a sub-namespace of it (`tool:acme.fetch_issue`) — expressive but unforgeable.
|
|
38
|
+
if (NAMESPACED_ID_RE.test(id) && (id === own || id.startsWith(`${own}.`)))
|
|
39
|
+
return id;
|
|
40
|
+
if (trusted && isCurated(id))
|
|
41
|
+
return id; // builtin claiming a curated id
|
|
42
|
+
return own;
|
|
43
|
+
}
|
|
44
|
+
/** Effects for a declaration: declared values, else fail-closed `['write']`. Curated ids use vocabulary. */
|
|
45
|
+
export function effectsOf(decl, id) {
|
|
46
|
+
const c = curated(id);
|
|
47
|
+
if (c)
|
|
48
|
+
return [...c.effects]; // curated metadata is canonical
|
|
49
|
+
const declared = decl?.effects;
|
|
50
|
+
if (Array.isArray(declared) && declared.length > 0 && declared.every((e) => e === 'read' || e === 'write'))
|
|
51
|
+
return [...declared];
|
|
52
|
+
return ['write']; // absent or unrecognized ⇒ fail-closed
|
|
53
|
+
}
|
|
54
|
+
/** Build the derived entity view for an id (curated metadata wins; else the declaring source's text). */
|
|
55
|
+
export function entityFor(id, fallback) {
|
|
56
|
+
const c = curated(id);
|
|
57
|
+
if (c)
|
|
58
|
+
return { id, name: c.name, description: c.description, effects: [...c.effects], curated: true };
|
|
59
|
+
return {
|
|
60
|
+
id,
|
|
61
|
+
name: fallback.name ?? id,
|
|
62
|
+
description: fallback.description ?? id,
|
|
63
|
+
effects: fallback.effects && fallback.effects.length ? [...fallback.effects] : ['write'],
|
|
64
|
+
curated: false,
|
|
65
|
+
};
|
|
66
|
+
}
|