ai-runtime-engine 2.7.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 CHANGED
@@ -5,6 +5,65 @@ All notable changes to `ai-runtime` are documented here. The format follows
5
5
  Versioning](https://semver.org/). Development history and rationale live in
6
6
  [docs/DECISIONS.md](docs/DECISIONS.md) and [docs/PROGRESS.md](docs/PROGRESS.md).
7
7
 
8
+ ## [2.8.0] — 2026-09-05
9
+
10
+ Agent work now **survives a crash**. Until this release an agent's progress existed only in memory: the
11
+ execution was written when it started and when it finished, so killing the process mid-run left a
12
+ record with no plan and no agent state, and resuming replanned from the bare goal — discarding
13
+ everything the agents had done. Additive: with `runtime.agents` absent, and with persistence disabled,
14
+ every prompt, key and rendered line is identical to 2.7.0.
15
+
16
+ ### Added
17
+
18
+ - **Persisted agent tasks.** `Execution.agentTasks` carries a versioned `AgentTaskRecord` per delegated
19
+ step: the inner plan with per-step statuses, the inner steps already completed, bounded inner
20
+ observations, an inner checkpoint, findings and admission diagnostics, and split call/tool accounting.
21
+ Read them with `parseAgentTasks` — the raw array is unvalidated JSON from a file, and each record is
22
+ schema-checked on read, fail-closed and per record.
23
+ - **Incremental commit points.** `executePlan` reports progress at four quiescent points, `orchestrate`
24
+ reports the plan once it is approved and within budget, and the worker reports each task transition —
25
+ including from *inside* the inner run, so an agent's own progress reaches disk while it is still
26
+ working. Everything a resume needs is on disk before the next wave starts.
27
+ - **Resumable inner waits.** An agent whose planner asks a question no longer fails its branch: the step
28
+ stays `pending`, the run pauses, and the answer is routed into *that agent's* inner goal. Two agents
29
+ can wait at once — the first claims the single pending slot and the rest are rediscovered as each is
30
+ answered.
31
+ - **Crash reconciliation.** A task left `running` by a process that no longer exists returns to `queued`
32
+ with an auditable `interruption` reason (`crash` / `pause` / `parent-cancel`). It runs on resume *and*
33
+ inside pause/cancel, so a cancelled execution cannot strand records that still claim to be running.
34
+ - **MCP is part of drift.** Checkpoints now fingerprint the MCP tools a plan references. A server that
35
+ changes a tool's shape, or drops it, while a plan sits paused is drift — the same as an edited file.
36
+ - **Findings survive a replan.** Drift replaces the plan, but a validated finding is a fact about the
37
+ workspace, so active findings are carried (bounded and fenced) into the new planning context.
38
+
39
+ ### Fixed
40
+
41
+ - **Deep redaction corrupted shared structure.** `redact()` used one visited-set for the whole
42
+ traversal, so a merely *shared* reference (not a cycle) became the string `"[circular]"`. Every
43
+ finding on an agent task shares one evidence array, so a task with two or more findings was written
44
+ to disk malformed and then dropped whole on read. Redaction now tracks the ancestor path; a genuine
45
+ cycle is still reported. This affects every caller of `redact`, not only agent state.
46
+ - **A cancellation could be overwritten by the run that was being cancelled.** The end-of-run write
47
+ bypassed the ownership rules, so a `cancelExecution` landing mid-run was replaced by `completed` —
48
+ and terminal, so the work could not be resumed either.
49
+ - **Secrets could reach the execution file.** Logs, telemetry, CLI output and MCP results all redact;
50
+ the execution store did not. Tool output and agent findings are now redacted at the persistence
51
+ boundary. Found by the phase's own leak scan, which is now a permanent test.
52
+ - A resumed run reported `failed` when it was merely waiting again, and its summary said so.
53
+ - `resume-execution <id>` with no answer replanned from the goal, destroying sibling agents' progress —
54
+ and `--answer` is optional in the CLI, so that was the ordinary invocation.
55
+ - Pause and cancel could be undone by an in-flight commit; a commit is now refused rather than
56
+ clobbering a status this owner cannot produce, and a refused commit stops the run instead of letting
57
+ two owners execute the same steps.
58
+
59
+ ### Changed
60
+
61
+ - `Finding` ids minted by a resumed attempt are namespaced (`…@a2`), and findings **merge** across
62
+ attempts rather than being replaced, so an interrupted attempt's findings are never orphaned.
63
+ - A persisted observation stores `data.findingIds` instead of a second copy of the findings.
64
+ - `nextAgentTaskId` includes the pid: under a frozen test clock two Runtimes in one process minted
65
+ colliding ids, which propagated into `Finding.id`.
66
+
8
67
  ## [2.7.0] — 2026-09-04
9
68
 
10
69
  Multi-agent core. A plan step can now **delegate to an agent** — a bounded sub-task with its own plan,
@@ -691,6 +750,7 @@ Initial release: the provider-agnostic AI **router** — capability-based routin
691
750
  scoring, evidence validation, fallback, health tracking, learning-based scoring, multi-model verification,
692
751
  budgets, MCP tools, OpenAPI-based adapter generation, and the `AI` class + CLI.
693
752
 
753
+ [2.8.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v2.8.0
694
754
  [2.7.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v2.7.0
695
755
  [2.6.0]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v2.6.0
696
756
  [2.5.1]: https://github.com/pavankhandelwal21/ai-runtime/releases/tag/v2.5.1
@@ -0,0 +1,36 @@
1
+ /**
2
+ * The closed role vocabulary for auto-decomposition (Phase 3.7).
3
+ *
4
+ * Auto-decomposition lets a goal produce agent steps with no operator-authored definition. The thing
5
+ * that makes it safe is what it does NOT do: no model-authored string ever becomes an objective, a tool
6
+ * id, or a permission. The model's only influence is choosing among the bounded agents offered to it —
7
+ * the same closed-set discipline `deriveCapabilitiesOffline` uses, applied to roles instead of ids.
8
+ *
9
+ * Every objective here is written in-tree by a human, because the objective is the one string that
10
+ * reaches the inner prompt UNFENCED (the step input beside it is fenced as untrusted). Every role is
11
+ * read-shaped, and adding a write-shaped one is a decision to record, not a line to slip in: the
12
+ * permission clamp would still deny it, but the catalog it is offered should not suggest otherwise.
13
+ */
14
+ import type { AgentDefinition } from './definition.js';
15
+ export interface AgentRole {
16
+ id: string;
17
+ /** IN-TREE and human-written. Never derived, never model-authored. */
18
+ objective: string;
19
+ /** What the role is for, shown in the plan catalog so the planner can choose between roles. */
20
+ description: string;
21
+ /**
22
+ * An output contract is MANDATORY for a derived role, though optional for an authored definition.
23
+ * `contractFailed` returns false when no contract is declared and the finding cap becomes infinite —
24
+ * so an agent nobody wrote would be the one agent whose output could never fail admission, and the
25
+ * one whose finding count is unbounded.
26
+ */
27
+ outputContract: NonNullable<AgentDefinition['outputContract']>;
28
+ }
29
+ /** Three roles ship in 3.0.0. All read-shaped; see the header before adding a fourth. */
30
+ export declare const AGENT_ROLES: readonly AgentRole[];
31
+ /** The id prefix reserved for synthesized agents, so one can never collide with an authored id. */
32
+ export declare const DERIVED_ID_PREFIX = "auto_";
33
+ /** The agent id a role synthesizes to. */
34
+ export declare function roleAgentId(roleId: string): string;
35
+ /** Whether an id belongs to the reserved synthesized namespace. */
36
+ export declare function isDerivedAgentId(id: string): boolean;
@@ -0,0 +1,44 @@
1
+ /**
2
+ * The closed role vocabulary for auto-decomposition (Phase 3.7).
3
+ *
4
+ * Auto-decomposition lets a goal produce agent steps with no operator-authored definition. The thing
5
+ * that makes it safe is what it does NOT do: no model-authored string ever becomes an objective, a tool
6
+ * id, or a permission. The model's only influence is choosing among the bounded agents offered to it —
7
+ * the same closed-set discipline `deriveCapabilitiesOffline` uses, applied to roles instead of ids.
8
+ *
9
+ * Every objective here is written in-tree by a human, because the objective is the one string that
10
+ * reaches the inner prompt UNFENCED (the step input beside it is fenced as untrusted). Every role is
11
+ * read-shaped, and adding a write-shaped one is a decision to record, not a line to slip in: the
12
+ * permission clamp would still deny it, but the catalog it is offered should not suggest otherwise.
13
+ */
14
+ /** Three roles ship in 3.0.0. All read-shaped; see the header before adding a fourth. */
15
+ export const AGENT_ROLES = [
16
+ {
17
+ id: 'investigate',
18
+ objective: 'Investigate the question you are given by reading the workspace. Report what you found as structured findings, each naming the file or symbol it is about. Do not modify anything.',
19
+ description: 'reads the workspace to answer a question, and reports findings',
20
+ outputContract: { types: ['observation'], maxFindings: 5, requireSubject: true },
21
+ },
22
+ {
23
+ id: 'verify',
24
+ objective: 'Check the specific claim you are given against the workspace. Report whether it holds, as structured findings that name what you checked. Do not modify anything.',
25
+ description: 'checks a specific claim against the workspace and reports whether it holds',
26
+ outputContract: { types: ['verification'], maxFindings: 5, requireSubject: true },
27
+ },
28
+ {
29
+ id: 'survey',
30
+ objective: 'Survey the area you are given and describe what is there: the files, their roles, and how they relate. Report structured findings. Do not modify anything.',
31
+ description: 'describes the shape of an area of the workspace',
32
+ outputContract: { types: ['survey'], maxFindings: 5, requireSubject: true },
33
+ },
34
+ ];
35
+ /** The id prefix reserved for synthesized agents, so one can never collide with an authored id. */
36
+ export const DERIVED_ID_PREFIX = 'auto_';
37
+ /** The agent id a role synthesizes to. */
38
+ export function roleAgentId(roleId) {
39
+ return `${DERIVED_ID_PREFIX}${roleId}`;
40
+ }
41
+ /** Whether an id belongs to the reserved synthesized namespace. */
42
+ export function isDerivedAgentId(id) {
43
+ return id.startsWith(DERIVED_ID_PREFIX);
44
+ }
@@ -0,0 +1,44 @@
1
+ /**
2
+ * Synthesizing agents from the registry (Phase 3.7) — auto-decomposition.
3
+ *
4
+ * THE TOOL SET IS THE WHOLE PROBLEM. A capability's providers are NAMESPACED ids (`tool:filesystem`)
5
+ * while the tool registry holds bare ones (`filesystem`), and `narrowEnvelope` intersects against the
6
+ * bare set. Emitting provider ids directly yields an empty catalog for every derived agent — a feature
7
+ * that is inert while looking correct, and whose obvious tests ("a derived agent cannot name a tool
8
+ * outside the registry") pass vacuously on the empty array. So the prefix is stripped here, membership
9
+ * in the parent catalog is asserted here, and the tests assert a NON-EMPTY result.
10
+ *
11
+ * WHAT IS NOT USED: BM25 relevance. It was measured to abstain on exactly the goals these roles exist
12
+ * for — "review the changed files", "find the bug", "audit the auth module" all derive nothing, because
13
+ * its thresholds were tuned for a different job (advisory capability hints, where a false positive
14
+ * merely over-advises). Gating whether an agent EXISTS on that calibration would make decomposition a
15
+ * silent no-op for the goals it is for. Roles are offered whenever they have tools; which one fits the
16
+ * goal is the planner's judgment, made from the role descriptions, which is exactly its job.
17
+ */
18
+ import type { AgentRole } from './roles.js';
19
+ import type { AgentDefinition } from './definition.js';
20
+ export interface DerivedAgent {
21
+ id: string;
22
+ roleId: string;
23
+ definition: AgentDefinition;
24
+ }
25
+ export interface SynthesizeInput {
26
+ /** Every capability the registry knows, with its effects and providers. */
27
+ capabilities: Array<{
28
+ id: string;
29
+ effects: readonly string[];
30
+ providers: string[];
31
+ }>;
32
+ /** The tool ids the parent actually holds — the set `narrowEnvelope` will intersect against. */
33
+ parentTools: string[];
34
+ roles?: readonly AgentRole[];
35
+ }
36
+ /**
37
+ * Offer one bounded agent per role that has at least one usable tool.
38
+ *
39
+ * A role draws the parent's tools that provide at least one READ-effect capability. Effects are
40
+ * advisory metadata and never authorization (invariant 25) — the permission clamp in `narrowEnvelope`
41
+ * is what actually denies writing. Using them here only NARROWS what a machine-generated agent is
42
+ * offered, which fails safe: a tool with no declared effects is inferred write-shaped and excluded.
43
+ */
44
+ export declare function synthesizeAgents(input: SynthesizeInput): DerivedAgent[];
@@ -0,0 +1,60 @@
1
+ /**
2
+ * Synthesizing agents from the registry (Phase 3.7) — auto-decomposition.
3
+ *
4
+ * THE TOOL SET IS THE WHOLE PROBLEM. A capability's providers are NAMESPACED ids (`tool:filesystem`)
5
+ * while the tool registry holds bare ones (`filesystem`), and `narrowEnvelope` intersects against the
6
+ * bare set. Emitting provider ids directly yields an empty catalog for every derived agent — a feature
7
+ * that is inert while looking correct, and whose obvious tests ("a derived agent cannot name a tool
8
+ * outside the registry") pass vacuously on the empty array. So the prefix is stripped here, membership
9
+ * in the parent catalog is asserted here, and the tests assert a NON-EMPTY result.
10
+ *
11
+ * WHAT IS NOT USED: BM25 relevance. It was measured to abstain on exactly the goals these roles exist
12
+ * for — "review the changed files", "find the bug", "audit the auth module" all derive nothing, because
13
+ * its thresholds were tuned for a different job (advisory capability hints, where a false positive
14
+ * merely over-advises). Gating whether an agent EXISTS on that calibration would make decomposition a
15
+ * silent no-op for the goals it is for. Roles are offered whenever they have tools; which one fits the
16
+ * goal is the planner's judgment, made from the role descriptions, which is exactly its job.
17
+ */
18
+ import { AGENT_ROLES, roleAgentId } from './roles.js';
19
+ /** Strip the `tool:` / `skill:` source prefix from a provider id. */
20
+ function bareProviderId(providerId) {
21
+ const at = providerId.indexOf(':');
22
+ return at < 0 ? { kind: '', id: providerId } : { kind: providerId.slice(0, at), id: providerId.slice(at + 1) };
23
+ }
24
+ /**
25
+ * Offer one bounded agent per role that has at least one usable tool.
26
+ *
27
+ * A role draws the parent's tools that provide at least one READ-effect capability. Effects are
28
+ * advisory metadata and never authorization (invariant 25) — the permission clamp in `narrowEnvelope`
29
+ * is what actually denies writing. Using them here only NARROWS what a machine-generated agent is
30
+ * offered, which fails safe: a tool with no declared effects is inferred write-shaped and excluded.
31
+ */
32
+ export function synthesizeAgents(input) {
33
+ const parent = new Set(input.parentTools);
34
+ const readable = new Set();
35
+ for (const cap of input.capabilities) {
36
+ if (!cap.effects.includes('read') || cap.effects.includes('write'))
37
+ continue;
38
+ for (const providerId of cap.providers) {
39
+ const { kind, id } = bareProviderId(providerId);
40
+ // Tools only: a skill brings its own tool requirements, and a derived agent gets no skills.
41
+ if (kind === 'tool' && parent.has(id))
42
+ readable.add(id);
43
+ }
44
+ }
45
+ const tools = [...readable].sort();
46
+ if (tools.length === 0)
47
+ return []; // nothing to read with ⇒ nothing worth offering
48
+ return (input.roles ?? AGENT_ROLES).map((role) => ({
49
+ id: roleAgentId(role.id),
50
+ roleId: role.id,
51
+ definition: {
52
+ objective: role.objective,
53
+ tools,
54
+ // No skills: a skill's declared tools would have to be re-checked against the envelope, and a
55
+ // derived agent has no operator to have vetted that.
56
+ skills: [],
57
+ outputContract: role.outputContract,
58
+ },
59
+ }));
60
+ }
@@ -9,33 +9,76 @@
9
9
  * normal state and records WHY in `interruption`, so the reason is auditable without growing the
10
10
  * lifecycle.
11
11
  */
12
- import type { PlanStepStatus } from '../orchestration/plan.js';
13
- import type { StepObservationCode } from '../orchestration/executor.js';
14
- import type { ExecutionStatus } from '../executions/execution.js';
12
+ import type { ExecutionPlan, PlanStepStatus } from '../orchestration/plan.js';
13
+ import type { StepObservation, StepObservationCode } from '../orchestration/executor.js';
14
+ import type { Checkpoint, ExecutionStatus } from '../executions/execution.js';
15
15
  import type { AdmissionRejection } from './admit.js';
16
16
  import type { Finding } from './finding.js';
17
17
  export type AgentTaskState = 'created' | 'queued' | 'running' | 'completed' | 'failed' | 'cancelled' | 'waiting_for_input' | 'waiting_for_clarification' | 'paused';
18
18
  export interface AgentTaskRecord {
19
- /** `ag_<base36 now>_<counter>`. `agentTaskId`, NEVER `taskId` - that name belongs to the router. */
19
+ /** Phase 3.5: record schema version. A record whose `v` is not 1 is DROPPED on read, never coerced —
20
+ * a future shape must not be silently reinterpreted as this one. */
21
+ v: 1;
22
+ /** `ag_<base36 now>_<pid>_<counter>`. `agentTaskId`, NEVER `taskId` - that name belongs to the router. */
20
23
  agentTaskId: string;
21
24
  agentId: string;
22
25
  stepId: string;
23
26
  state: AgentTaskState;
24
27
  createdAt: number;
28
+ /** Phase 3.5: last write. Distinct from `endedAt` — a task is written many times before it ends. */
29
+ updatedAt: number;
25
30
  startedAt?: number;
26
31
  endedAt?: number;
27
- /** Additive, and NOT a state. Carries why a non-terminal task stopped. */
32
+ /** Additive, and NOT a state. Carries why a non-terminal task stopped. Cleared when a resumed task is
33
+ * promoted back to `running`, so the stamp always describes the LATEST interruption (Phase 3.5). */
28
34
  interruption?: {
29
35
  kind: 'crash' | 'pause' | 'parent-cancel';
30
36
  at: number;
31
37
  detail?: string;
32
38
  };
39
+ /** Which run/plan minted this record. */
40
+ provenance: {
41
+ executionId?: string;
42
+ planVersion: number;
43
+ };
44
+ /** Hash of the AgentDefinition. An operator who edits the definition invalidates the inner plan. */
45
+ agentDefHash: string;
46
+ /** Hash of the NARROWED envelope — the tools/permissions/caps the inner plan was built against. */
47
+ envelopeHash: string;
48
+ /** Hash of the STEP, not its id. Plan step ids (`s1`, `auto1`) recur across replans, so id-matching
49
+ * would bind a record to a different step's work; the input is what makes the step this step. */
50
+ stepInputHash: string;
51
+ /** How many times this task has been started. Findings minted on attempt 2 are namespaced by it, so a
52
+ * resumed attempt can never mint an id that collides with a finding already on the record. */
53
+ attempt: number;
33
54
  innerPlanVersion?: number;
55
+ /** The inner plan with per-step statuses. Dropped (never truncated) when oversized — a half-written
56
+ * plan is worse than none, because it would be executed. */
57
+ innerPlan?: ExecutionPlan;
58
+ /** THE resume primitive: inner steps already done, fed straight to `executePlan`'s `skip`. Survives
59
+ * `innerPlan` being dropped, so a too-large plan still costs re-planning, never re-execution. */
60
+ innerCompletedSteps: string[];
61
+ /** Bounded slice of inner observations, in executor order. */
62
+ innerObservations: StepObservation[];
63
+ innerObservationsOmitted: number;
64
+ /** Workspace fingerprint of the inner run — the outer checkpoint cannot see files only an agent touched. */
65
+ innerCheckpoint?: Checkpoint;
66
+ /** The one inner wait that is reachable: the inner planner asked a question. */
67
+ pendingInner?: {
68
+ kind: 'clarification';
69
+ question: string;
70
+ at: number;
71
+ };
34
72
  innerSteps: {
35
73
  total: number;
36
74
  succeeded: number;
37
75
  };
76
+ /** What the step RESERVED from the outer pool (`envelope.reservation`). */
77
+ callsReserved: number;
78
+ /** Cumulative across attempts. Charged, never refunded downward. */
38
79
  callsUsed: number;
80
+ /** DERIVED for reporting only — `max(0, reserved - used)`. Nothing computes a budget from this. */
81
+ callsRefunded: number;
39
82
  toolCallsUsed: number;
40
83
  findings: Finding[];
41
84
  /** Candidates the admission pipeline REFUSED. Auditable, and never threaded anywhere. */
@@ -45,6 +88,10 @@ export interface AgentTaskRecord {
45
88
  message: string;
46
89
  };
47
90
  }
91
+ /** Terminal agent-task states: reached once, never left. A commit may not move a task out of one. */
92
+ export declare const AGENT_TERMINAL: ReadonlySet<AgentTaskState>;
93
+ /** States a persisted task may be resumed from. Exactly the complement of AGENT_TERMINAL. */
94
+ export declare const AGENT_RESUMABLE: ReadonlySet<AgentTaskState>;
48
95
  /** One row per agent-task state. Read this table; never re-derive a projection at a call site. */
49
96
  export interface ProjectionRow {
50
97
  step: PlanStepStatus;
@@ -56,5 +103,10 @@ export interface ProjectionRow {
56
103
  export declare const AGENT_TASK_PROJECTION: Readonly<Record<AgentTaskState, ProjectionRow>>;
57
104
  /** The states an agent task can actually reach in 3.4. */
58
105
  export declare const REACHABLE_IN_34: AgentTaskState[];
59
- /** Mint an agent task id. Base36 clock + a counter, so ids are stable under a fake clock. */
106
+ /**
107
+ * Mint an agent task id. Base36 clock + PID + a counter, so ids are stable under a fake clock and still
108
+ * unique across processes. The pid is not decoration: tests freeze the clock, and two Runtimes in one
109
+ * process would otherwise mint the same id — a collision that propagates straight into `Finding.id`
110
+ * (`${agentTaskId}_f<n>`) and would silently merge two agents' findings.
111
+ */
60
112
  export declare function nextAgentTaskId(now: number): string;
@@ -9,6 +9,17 @@
9
9
  * normal state and records WHY in `interruption`, so the reason is auditable without growing the
10
10
  * lifecycle.
11
11
  */
12
+ /** Terminal agent-task states: reached once, never left. A commit may not move a task out of one. */
13
+ export const AGENT_TERMINAL = new Set(['completed', 'failed', 'cancelled']);
14
+ /** States a persisted task may be resumed from. Exactly the complement of AGENT_TERMINAL. */
15
+ export const AGENT_RESUMABLE = new Set([
16
+ 'created',
17
+ 'queued',
18
+ 'running',
19
+ 'waiting_for_input',
20
+ 'waiting_for_clarification',
21
+ 'paused',
22
+ ]);
12
23
  export const AGENT_TASK_PROJECTION = {
13
24
  created: { step: 'pending', exec: 'running', reachableIn34: true },
14
25
  queued: { step: 'pending', exec: 'running', reachableIn34: true },
@@ -25,8 +36,13 @@ export const AGENT_TASK_PROJECTION = {
25
36
  /** The states an agent task can actually reach in 3.4. */
26
37
  export const REACHABLE_IN_34 = Object.keys(AGENT_TASK_PROJECTION).filter((s) => AGENT_TASK_PROJECTION[s].reachableIn34);
27
38
  let counter = 0;
28
- /** Mint an agent task id. Base36 clock + a counter, so ids are stable under a fake clock. */
39
+ /**
40
+ * Mint an agent task id. Base36 clock + PID + a counter, so ids are stable under a fake clock and still
41
+ * unique across processes. The pid is not decoration: tests freeze the clock, and two Runtimes in one
42
+ * process would otherwise mint the same id — a collision that propagates straight into `Finding.id`
43
+ * (`${agentTaskId}_f<n>`) and would silently merge two agents' findings.
44
+ */
29
45
  export function nextAgentTaskId(now) {
30
46
  counter += 1;
31
- return `ag_${now.toString(36)}_${counter.toString(36)}`;
47
+ return `ag_${now.toString(36)}_${process.pid.toString(36)}_${counter.toString(36)}`;
32
48
  }
@@ -27,6 +27,12 @@ import type { PermissionPolicy } from '../runtime/policy.js';
27
27
  import type { Clock } from '../util/clock.js';
28
28
  /** How much of a step input may reach the inner prompt. It is model-authored, and it is fenced. */
29
29
  export declare const AGENT_INPUT_MAX = 1000;
30
+ /** Inner observations persisted per task. The record lives in a JSON file that is rewritten every
31
+ * commit, so this is a durability bound, not a display one. Beyond it, the count is kept and the
32
+ * content dropped — an honest "there was more" rather than a silently short list. */
33
+ export declare const INNER_OBS_MAX = 20;
34
+ /** Admission rejections kept per task. They accumulate across attempts and are rewritten every commit. */
35
+ export declare const DIAGNOSTICS_KEPT = 50;
30
36
  export interface AgentWorkerDeps {
31
37
  ai: AI;
32
38
  clock: Clock;
@@ -60,7 +66,24 @@ export interface AgentWorkerDeps {
60
66
  executionId?: string;
61
67
  planVersion: number;
62
68
  };
69
+ /** Phase 3.5: called whenever the record MATERIALLY changes, so inner progress reaches disk while the
70
+ * agent is still running. Without a seam inside the inner run, everything between `running` and
71
+ * `finish()` — the inner plan, every completed inner step, every inner model call — is lost to a
72
+ * crash, and the resume has nothing to skip. Synchronous; must not throw. */
73
+ onRecord?: (record: AgentTaskRecord) => void;
74
+ /** Phase 3.5: a persisted record to CONTINUE instead of minting a fresh one. The caller proves it
75
+ * belongs to THIS step by step-input hash before passing it. */
76
+ resume?: AgentTaskRecord;
77
+ /** Phase 3.5: the human's answer to this task's inner clarification. Model-authored text from the
78
+ * user, so it is fenced into the inner goal like any other untrusted input. */
79
+ resumeAnswer?: string;
63
80
  }
81
+ /**
82
+ * What makes a step THIS step. Plan step ids (`s1`, `auto1`) are model-authored and recur across
83
+ * replans, so binding a persisted record by id alone would hand one step's completed inner work to a
84
+ * different step that happens to share its id — same agent, different input, silently wrong findings.
85
+ */
86
+ export declare function stepIdentity(step: PlanStep): string;
64
87
  export interface RunAgentTaskResult {
65
88
  observation: StepObservation;
66
89
  record: AgentTaskRecord;