@loopingai/core 0.5.1 → 0.6.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.
Files changed (88) hide show
  1. package/README.md +6 -4
  2. package/dist/a2a/notify.d.ts +4 -3
  3. package/dist/a2a/notify.js +4 -3
  4. package/dist/agent/anthropic/index.d.ts +15 -0
  5. package/dist/agent/anthropic/index.js +19 -0
  6. package/dist/agent/anthropic/language-model.d.ts +59 -0
  7. package/dist/agent/anthropic/language-model.js +442 -0
  8. package/dist/agent/anthropic/prompt.d.ts +84 -0
  9. package/dist/agent/anthropic/prompt.js +541 -0
  10. package/dist/agent/anthropic/runtime.d.ts +79 -0
  11. package/dist/agent/anthropic/runtime.js +130 -0
  12. package/dist/agent/control.js +10 -9
  13. package/dist/agent/errors.d.ts +85 -0
  14. package/dist/agent/errors.js +64 -0
  15. package/dist/agent/final-reply.d.ts +14 -13
  16. package/dist/agent/final-reply.js +28 -11
  17. package/dist/agent/history.d.ts +3 -3
  18. package/dist/agent/history.js +2 -2
  19. package/dist/agent/index.d.ts +4 -2
  20. package/dist/agent/index.js +4 -2
  21. package/dist/agent/inference.d.ts +58 -1
  22. package/dist/agent/inference.js +44 -0
  23. package/dist/agent/model.d.ts +42 -25
  24. package/dist/agent/model.js +1 -48
  25. package/dist/agent/session.d.ts +6 -7
  26. package/dist/agent/session.js +3 -3
  27. package/dist/agent/workers-ai/index.d.ts +23 -0
  28. package/dist/agent/workers-ai/index.js +23 -0
  29. package/dist/agent/workers-ai/runtime.d.ts +42 -0
  30. package/dist/agent/workers-ai/runtime.js +63 -0
  31. package/dist/config.d.ts +49 -15
  32. package/dist/config.js +30 -1
  33. package/dist/contract/plugin.d.ts +63 -3
  34. package/dist/contract/plugin.js +76 -0
  35. package/dist/contract/recipe.d.ts +16 -17
  36. package/dist/db/db.d.ts +0 -1
  37. package/dist/db/migrations/index.js +8 -1
  38. package/dist/db/models/subtasks.d.ts +24 -25
  39. package/dist/db/models/subtasks.js +33 -76
  40. package/dist/db/schema.d.ts +2 -21
  41. package/dist/db/schema.js +2 -4
  42. package/dist/host/agent.d.ts +58 -4
  43. package/dist/host/agent.js +63 -9
  44. package/dist/index.d.ts +2 -2
  45. package/dist/index.js +2 -2
  46. package/dist/platform.d.ts +74 -11
  47. package/dist/platform.js +76 -13
  48. package/dist/round/agent.d.ts +36 -31
  49. package/dist/round/agent.js +61 -89
  50. package/dist/round/index.d.ts +3 -2
  51. package/dist/round/index.js +2 -2
  52. package/dist/round/policy.d.ts +2 -2
  53. package/dist/round/subagent.d.ts +19 -1
  54. package/dist/round/subagent.js +22 -5
  55. package/dist/round/turn.d.ts +32 -13
  56. package/dist/round/turn.js +83 -16
  57. package/dist/round/workflow.d.ts +23 -7
  58. package/dist/round/workflow.js +132 -65
  59. package/dist/runtime/index.d.ts +4 -2
  60. package/dist/runtime/index.js +6 -0
  61. package/dist/subagent/fingerprint.d.ts +2 -2
  62. package/dist/subagent/fingerprint.js +8 -17
  63. package/dist/subagent/index.d.ts +6 -4
  64. package/dist/subagent/index.js +8 -6
  65. package/dist/subagent/prompt.d.ts +4 -5
  66. package/dist/subagent/prompt.js +0 -8
  67. package/dist/subagent/run.d.ts +8 -1
  68. package/dist/subagent/run.js +59 -9
  69. package/dist/subtasks/catalog.d.ts +1 -1
  70. package/dist/subtasks/catalog.js +1 -1
  71. package/dist/subtasks/decomposition.d.ts +16 -20
  72. package/dist/subtasks/decomposition.js +27 -75
  73. package/dist/subtasks/delegate.d.ts +20 -1
  74. package/dist/subtasks/delegate.js +21 -16
  75. package/dist/subtasks/index.d.ts +1 -2
  76. package/dist/subtasks/index.js +1 -2
  77. package/dist/subtasks/subtask-types.d.ts +0 -8
  78. package/dist/subtasks/subtask-types.js +0 -7
  79. package/dist/subtasks/types.d.ts +45 -70
  80. package/dist/testing/mock-model.d.ts +35 -0
  81. package/dist/testing/mock-model.js +75 -0
  82. package/dist/testing/vcr-global-setup.d.ts +1 -3
  83. package/dist/testing/vcr-global-setup.js +1 -3
  84. package/dist/worker/index.d.ts +5 -12
  85. package/dist/worker/index.js +5 -12
  86. package/package.json +19 -1
  87. package/dist/subtasks/scheduler.d.ts +0 -48
  88. package/dist/subtasks/scheduler.js +0 -47
@@ -36,3 +36,79 @@ export function definePlugin(plugin) {
36
36
  contractVersion: plugin.contractVersion ?? PLUGIN_CONTRACT_VERSION
37
37
  };
38
38
  }
39
+ /**
40
+ * A plugin whose tool families stay registered but whose main-agent surface is
41
+ * narrowed, or removed.
42
+ *
43
+ * ## Why this has to exist
44
+ *
45
+ * `validateRecipe` runs on the **parent** and drops any tool family no installed
46
+ * plugin registered. So an agent that wants "my subagents can run a shell, I
47
+ * cannot" has a problem: uninstalling the sandbox plugin from the parent also
48
+ * deletes `sandbox` from every recipe the parent validates, and the subagents
49
+ * silently lose it. The parent has to install the plugin and decline its tools,
50
+ * which is exactly what this expresses.
51
+ *
52
+ * ```ts
53
+ * // The parent registers the family, and gets three read-only tools from it.
54
+ * restrictMainAgentTools(sandbox(cfg), {
55
+ * allow: ["sb_read", "sb_ls", "sb_exists"],
56
+ * capability: "You can read the checkout, not change it."
57
+ * })
58
+ * ```
59
+ *
60
+ * ## What passes through untouched
61
+ *
62
+ * Everything else: `toolFamilies`, `subtaskType`, `resolveRuntime`, `store`,
63
+ * `requires`, and the lifecycle hooks. In particular `requires` must survive, or
64
+ * a parent that stops offering `sb_exec` also stops asserting that the `Sandbox`
65
+ * binding exists — and the failure moves from DO start to a subagent's first
66
+ * tool call.
67
+ *
68
+ * ## Why a missing name logs instead of throwing
69
+ *
70
+ * `mainAgentTools` is async and called per turn, so a throw here lands inside a
71
+ * request someone is waiting on — a bad trade for what is always a typo. The
72
+ * error names the plugin and the tool so it is greppable, and the honest place
73
+ * to catch it is a test over the assembled surface, which is cheap to write and
74
+ * fails the build instead.
75
+ */
76
+ export function restrictMainAgentTools(plugin, options) {
77
+ const allow = new Set(options.allow);
78
+ // Spread first so anything added to `AgentPlugin` later keeps flowing through
79
+ // without an edit here — the failure mode of an explicit field list is a
80
+ // capability that silently stops reaching the parent.
81
+ const restricted = { ...plugin };
82
+ if (allow.size === 0) {
83
+ delete restricted.mainAgentTools;
84
+ }
85
+ else {
86
+ // Installed even when the plugin offers no `mainAgentTools` at all, rather
87
+ // than left `undefined`. A plugin with no main-agent surface is not the
88
+ // "nothing to narrow" case it looks like — it is an allowlist naming tools
89
+ // that do not exist, which is precisely the typo this helper promises to
90
+ // report, and the one shape of it that is hardest to spot: a whole surface
91
+ // removed or renamed disappears without a word. The assembled tool set is
92
+ // identical either way, because `runtime.mainAgentTools` merges each
93
+ // plugin's result and an empty one contributes nothing.
94
+ const inner = plugin.mainAgentTools;
95
+ restricted.mainAgentTools = async (ctx) => {
96
+ const all = inner ? await inner(ctx) : {};
97
+ const kept = {};
98
+ for (const name of allow) {
99
+ if (name in all)
100
+ kept[name] = all[name];
101
+ else
102
+ console.error(`[plugin] "${plugin.key}" offers no main-agent tool "${name}" — ` +
103
+ "the allowlist names a tool that does not exist, so the main " +
104
+ "agent is quietly missing it. Check for a rename.");
105
+ }
106
+ return kept;
107
+ };
108
+ }
109
+ if (options.capability === undefined)
110
+ delete restricted.capability;
111
+ else
112
+ restricted.capability = options.capability;
113
+ return restricted;
114
+ }
@@ -48,12 +48,10 @@ export interface DelegationNames {
48
48
  *
49
49
  * Deliberately *not* here: how a run is sliced into durable chunks. That is a
50
50
  * Workers step-timeout constraint, it is identical for every Recipe, and it lives
51
- * in {@link file://../platform.ts}. It used to include a `turnsPerChunk`, on the
52
- * theory that a turn count could keep a step under the timeout it cannot,
53
- * because nothing predicts how long a turn takes, and the resulting arithmetic was
54
- * wrong by 2-3× in practice.
55
- */
56
- /**
51
+ * in {@link file://../platform.ts}. A turn count cannot do that job — nothing
52
+ * predicts how long a turn takes so no per-chunk turn budget belongs on a
53
+ * Recipe.
54
+ *
57
55
  * Structurally identical to {@link AgentLimits} — a budget is a budget at both
58
56
  * levels, and keeping one shape means `resolveLimits` merges a recipe's
59
57
  * declaration straight over the host's baseline with no translation. Aliased
@@ -81,8 +79,10 @@ export interface ResolvedRecipe {
81
79
  toolFamilies: string[];
82
80
  enabled: boolean;
83
81
  /**
84
- * Only the budget fields this Recipe overrides; the rest come from
85
- * `SUBAGENT_LIMITS`. `{}` means "the baseline", which is what most Recipes want.
82
+ * Only the budget fields this Recipe overrides; the rest come from the host's
83
+ * `CoreConfig.subagentLimits`, which reaches validation as
84
+ * `RecipePolicy.baselineLimits`. `{}` means "the baseline", which is what most
85
+ * Recipes want.
86
86
  */
87
87
  limits: Partial<RecipeLimits>;
88
88
  /**
@@ -107,8 +107,8 @@ export interface ValidatedRecipe extends ResolvedRecipe {
107
107
  *
108
108
  * These exist only here, never on {@link ResolvedRecipe}, and that asymmetry
109
109
  * is the design: a recipe cannot state a model, so the only way to hold one is
110
- * to have been through {@link validateRecipe}, which copies the host's. Recipe
111
- * data has no path to influence them.
110
+ * to have been through `validateRecipe`, which copies the host's. Recipe data
111
+ * has no path to influence them.
112
112
  *
113
113
  * Guaranteed non-empty and guaranteed distinct, because `resolveConfig`
114
114
  * refuses a config that is either — so the fallback is always a genuinely
@@ -145,9 +145,9 @@ export interface SubtaskTypeSpec {
145
145
  *
146
146
  * A `z.object`, not an opaque `z.ZodType`, and that is load-bearing: the agent
147
147
  * reads `.shape` back to build the `params` field of the delegate tool's schema
148
- * (see `subtaskParamProperties`). Declaring a param the model is never shown is
149
- * the failure this shape exists to prevent — describe each key with
150
- * `.describe()`, because that text is what the model reads.
148
+ * (see `SubtaskTypeRegistry.paramProperties`). Declaring a param the model is
149
+ * never shown is the failure this shape exists to prevent — describe each key
150
+ * with `.describe()`, because that text is what the model reads.
151
151
  */
152
152
  params: SubtaskParamsSchema | null;
153
153
  /** How the model is told to obtain each param, appended to the description. */
@@ -159,10 +159,9 @@ export interface SubtaskTypeSpec {
159
159
  *
160
160
  * These two prompt fields exist to hold one rule: **everything the main agent
161
161
  * is told about a domain is declared here, never written inside the runtime.**
162
- * Both of them started as hand-written blocks in `agent/prompt.ts` and
163
- * `agent/turn.ts`; the main agent read the same advice twice per round from two
164
- * files that had already drifted into contradicting each other on how many
165
- * subtasks a multi-game request gets.
162
+ * Advice written in the runtime instead has to be repeated per call site, and
163
+ * the copies drift into telling the main agent two contradictory things
164
+ * about the same domain, twice per round.
166
165
  */
167
166
  capability?: string;
168
167
  /**
package/dist/db/db.d.ts CHANGED
@@ -130,7 +130,6 @@ export declare class AgentDB {
130
130
  }): boolean;
131
131
  complete(id: import("../subtasks/types.js").SubtaskId, resultParts: import("../subtasks/types.js").SubtaskResultPart[]): boolean;
132
132
  fail(id: import("../subtasks/types.js").SubtaskId, error: string): boolean;
133
- skip(id: import("../subtasks/types.js").SubtaskId): boolean;
134
133
  cancelRunning(id: import("../subtasks/types.js").SubtaskId): boolean;
135
134
  cancelPending(taskId: string): number;
136
135
  cleanup(): void;
@@ -6,6 +6,12 @@ const dbMigrations = {
6
6
  when: 1785537420937,
7
7
  tag: "0000_init",
8
8
  breakpoints: true
9
+ },
10
+ {
11
+ idx: 1,
12
+ when: 1786871925215,
13
+ tag: "0001_drop_depends_on",
14
+ breakpoints: true
9
15
  }
10
16
  ]
11
17
  },
@@ -47,7 +53,8 @@ CREATE TABLE \`subtasks\` (
47
53
  CREATE UNIQUE INDEX \`idx_subtasks_task_ordinal\` ON \`subtasks\` (\`task_id\`,\`ordinal\`);--> statement-breakpoint
48
54
  CREATE INDEX \`idx_subtasks_task_round\` ON \`subtasks\` (\`task_id\`,\`round\`);--> statement-breakpoint
49
55
  CREATE INDEX \`idx_subtasks_status\` ON \`subtasks\` (\`status\`);--> statement-breakpoint
50
- CREATE INDEX \`idx_subtasks_created_at\` ON \`subtasks\` (\`created_at\`);`
56
+ CREATE INDEX \`idx_subtasks_created_at\` ON \`subtasks\` (\`created_at\`);`,
57
+ m0001: `ALTER TABLE \`subtasks\` DROP COLUMN \`depends_on_json\`;`
51
58
  }
52
59
  };
53
60
  export default dbMigrations;
@@ -15,28 +15,22 @@ export interface SubtaskModelOptions {
15
15
  * durable-sqlite is synchronous; the multi-statement `createDecomposition` runs
16
16
  * inside an explicit `db.transaction` (drizzle maps it to
17
17
  * `storage.transactionSync`), so a mid-create failure rolls back every statement
18
- * instead of leaving a partial DAG. Guarded transitions filter on the expected
18
+ * instead of leaving a partial decomposition. Guarded transitions filter on the expected
19
19
  * current `status`, so a disallowed transition matches no row and is a no-op.
20
20
  */
21
21
  export declare function makeSubtasks(db: DB, opts: SubtaskModelOptions): {
22
22
  /**
23
- * Create one **round's** decomposition atomically: the entire
24
- * check-validate-insert sequence runs in one synchronous `db.transaction`, so
25
- * a failure anywhere rolls back every statement no truncated subtask set, no
26
- * nodes left with unwritten dependency edges. Idempotent on
27
- * `(taskId, round)`: if this round already has Subtasks, returns them
28
- * unchanged (a Workflow-step retry must not duplicate work); the unique
29
- * `(task_id, ordinal)` index is the schema-level backstop. Enforces the 1..8
30
- * per-round bound, unique local keys, and resolvable non-self dependency
31
- * edges, then resolves draft-local dependency keys to the SQLite-assigned
32
- * {@link SubtaskId}s. Full cycle/edge DAG validation lives in the turn
33
- * operation; this is the storage guard.
23
+ * Create one **round's** decomposition atomically: the whole check-and-insert
24
+ * sequence runs in one synchronous `db.transaction`, so a failure anywhere
25
+ * rolls back every statement rather than leaving a truncated subtask set.
26
+ * Idempotent on `(taskId, round)`: if this round already has Subtasks,
27
+ * returns them unchanged (a Workflow-step retry must not duplicate work); the
28
+ * unique `(task_id, ordinal)` index is the schema-level backstop. Enforces the
29
+ * 1..8 per-round bound as the durable guard.
34
30
  *
35
31
  * `ordinal` continues across rounds (it is the Task-wide position, and what
36
32
  * the unique index is built on), so a later round's rows sort after the
37
- * earlier ones in {@link list}. Dependency edges never cross a round: each
38
- * round's DAG is self-contained, and the drafts handed here only carry keys
39
- * from their own round.
33
+ * earlier ones in {@link list}.
40
34
  */
41
35
  createDecomposition(taskId: string, round: number, drafts: SubtaskDraft[]): Subtask[];
42
36
  /** Load one Subtask by id. */
@@ -44,9 +38,10 @@ export declare function makeSubtasks(db: DB, opts: SubtaskModelOptions): {
44
38
  /** List every round's Subtasks for a Task, in ordinal order. */
45
39
  list(taskId: string): Subtask[];
46
40
  /**
47
- * List one round's Subtasks, in ordinal order — the scheduler's view. Phase 2
48
- * drives a single round's DAG at a time, so it must not see a sibling round's
49
- * rows; composition, by contrast, reads {@link list} across all rounds.
41
+ * List one round's Subtasks, in ordinal order — what the Workflow's `scan`
42
+ * projects. It executes a single round at a time, so it must not see a
43
+ * sibling round's rows; a later round, by contrast, reads {@link list}
44
+ * across all rounds to reunite each `delegate` call with its results.
50
45
  */
51
46
  listRound(taskId: string, round: number): Subtask[];
52
47
  /**
@@ -70,17 +65,14 @@ export declare function makeSubtasks(db: DB, opts: SubtaskModelOptions): {
70
65
  * Both sides are reachable and both must land. A child's failed result arrives
71
66
  * on a `running` row. The Workflow's last resort — `failSubtask`, once
72
67
  * `execute:<id>` has exhausted every retry — can arrive on either:
73
- * `executeSubtask` may throw before its `pending -> running` claim (a
74
- * dependency-invariant fault) or after it (a transient child fault). Leaving
75
- * either behind strands the row: a `pending` node re-enters the next wave
76
- * forever, and a `running` node blocks its dependents, which {@link skip} only
77
- * propagates past *failed* prerequisites.
68
+ * `executeSubtask` may throw before its `pending -> running` claim (an
69
+ * unresolvable recipe) or after it (a transient child fault). Leaving either
70
+ * behind strands the row in a non-terminal state that nobody is coming back
71
+ * to resolve.
78
72
  *
79
73
  * Returns false once the row is terminal — a late loser to the real result.
80
74
  */
81
75
  fail(id: SubtaskId, error: string): boolean;
82
- /** Skip a Subtask blocked by a failed/skipped dependency: guarded `pending -> skipped`. */
83
- skip(id: SubtaskId): boolean;
84
76
  /**
85
77
  * Discard a late result after parent cancellation: guarded
86
78
  * `running -> canceled`. The parent calls this when a child returned a
@@ -94,6 +86,13 @@ export declare function makeSubtasks(db: DB, opts: SubtaskModelOptions): {
94
86
  * Running Subtasks are left alone here — the parent transitions those with
95
87
  * {@link cancelRunning} once their in-flight result comes back and is
96
88
  * discarded. Returns the number canceled.
89
+ *
90
+ * Keyed on the Task rather than one id, so this is the one guarded
91
+ * transition that cannot go through {@link transition} and sets its own
92
+ * timestamps. `completedAt` is part of that and not optional: every other
93
+ * terminal write records it, and a terminal row without one reads as still
94
+ * in flight to anything measuring how long a Subtask took or when a Task
95
+ * actually stopped.
97
96
  */
98
97
  cancelPending(taskId: string): number;
99
98
  /** Delete Subtasks older than 30 days (called by the weekly maintenance cron). */
@@ -11,7 +11,6 @@ const resultPartSchema = z.object({
11
11
  });
12
12
  const referencesSchema = z.array(referenceSchema);
13
13
  const resultPartsSchema = z.array(resultPartSchema);
14
- const dependsOnSchema = z.array(z.number().int());
15
14
  const paramsSchema = z.record(z.string(), z.string());
16
15
  /**
17
16
  * Query methods for the `subtasks` table (durable decomposed units of work).
@@ -20,7 +19,7 @@ const paramsSchema = z.record(z.string(), z.string());
20
19
  * durable-sqlite is synchronous; the multi-statement `createDecomposition` runs
21
20
  * inside an explicit `db.transaction` (drizzle maps it to
22
21
  * `storage.transactionSync`), so a mid-create failure rolls back every statement
23
- * instead of leaving a partial DAG. Guarded transitions filter on the expected
22
+ * instead of leaving a partial decomposition. Guarded transitions filter on the expected
24
23
  * current `status`, so a disallowed transition matches no row and is a no-op.
25
24
  */
26
25
  export function makeSubtasks(db, opts) {
@@ -34,7 +33,6 @@ export function makeSubtasks(db, opts) {
34
33
  recipeVersion: row.recipeVersion,
35
34
  prompt: row.prompt,
36
35
  references: referencesSchema.parse(JSON.parse(row.referencesJson)),
37
- dependsOn: dependsOnSchema.parse(JSON.parse(row.dependsOnJson)),
38
36
  params: paramsSchema.parse(JSON.parse(row.paramsJson)),
39
37
  status: row.status,
40
38
  resultParts: row.resultPartsJson === null
@@ -74,23 +72,17 @@ export function makeSubtasks(db, opts) {
74
72
  };
75
73
  return {
76
74
  /**
77
- * Create one **round's** decomposition atomically: the entire
78
- * check-validate-insert sequence runs in one synchronous `db.transaction`, so
79
- * a failure anywhere rolls back every statement no truncated subtask set, no
80
- * nodes left with unwritten dependency edges. Idempotent on
81
- * `(taskId, round)`: if this round already has Subtasks, returns them
82
- * unchanged (a Workflow-step retry must not duplicate work); the unique
83
- * `(task_id, ordinal)` index is the schema-level backstop. Enforces the 1..8
84
- * per-round bound, unique local keys, and resolvable non-self dependency
85
- * edges, then resolves draft-local dependency keys to the SQLite-assigned
86
- * {@link SubtaskId}s. Full cycle/edge DAG validation lives in the turn
87
- * operation; this is the storage guard.
75
+ * Create one **round's** decomposition atomically: the whole check-and-insert
76
+ * sequence runs in one synchronous `db.transaction`, so a failure anywhere
77
+ * rolls back every statement rather than leaving a truncated subtask set.
78
+ * Idempotent on `(taskId, round)`: if this round already has Subtasks,
79
+ * returns them unchanged (a Workflow-step retry must not duplicate work); the
80
+ * unique `(task_id, ordinal)` index is the schema-level backstop. Enforces the
81
+ * 1..8 per-round bound as the durable guard.
88
82
  *
89
83
  * `ordinal` continues across rounds (it is the Task-wide position, and what
90
84
  * the unique index is built on), so a later round's rows sort after the
91
- * earlier ones in {@link list}. Dependency edges never cross a round: each
92
- * round's DAG is self-contained, and the drafts handed here only carry keys
93
- * from their own round.
85
+ * earlier ones in {@link list}.
94
86
  */
95
87
  createDecomposition(taskId, round, drafts) {
96
88
  return db.transaction(() => {
@@ -100,38 +92,14 @@ export function makeSubtasks(db, opts) {
100
92
  if (drafts.length < 1 || drafts.length > opts.maxSubtasks) {
101
93
  throw new Error(`decomposition must have 1..${opts.maxSubtasks} subtasks, got ${drafts.length}`);
102
94
  }
103
- // Register every local key first, rejecting duplicates. This is its own
104
- // pass because the dependency check below reads the complete key set: an
105
- // edge may point forward to a draft defined later, so every key must be
106
- // known before any edge is validated.
107
- const keys = new Set();
108
- for (const d of drafts) {
109
- if (keys.has(d.localKey)) {
110
- throw new Error(`duplicate draft local key: ${d.localKey}`);
111
- }
112
- keys.add(d.localKey);
113
- }
114
- for (const d of drafts) {
115
- for (const dep of d.dependsOn) {
116
- // A subtask cannot depend on itself.
117
- if (dep === d.localKey) {
118
- throw new Error(`subtask ${d.localKey} depends on itself`);
119
- }
120
- // Every edge must resolve to a key in this decomposition.
121
- if (!keys.has(dep)) {
122
- throw new Error(`subtask ${d.localKey} depends on unknown key: ${dep}`);
123
- }
124
- }
125
- // References must match the persisted shape before we serialize them.
95
+ // References must match the persisted shape before we serialize them.
96
+ for (const d of drafts)
126
97
  referencesSchema.parse(d.references);
127
- }
128
98
  const now = Date.now();
129
- const keyToId = new Map();
130
- // Pass 1: insert nodes (deps empty for now) to assign ids. Ordinals
131
- // continue above every earlier round's rows from the current maximum,
132
- // not a row count, so a gap left by {@link cleanup} deleting part of a
133
- // Task cannot hand a later round an ordinal that is already taken. Both
134
- // read the `(task_id, ordinal)` index; only this one stays monotonic.
99
+ // Ordinals continue above every earlier round's rows — from the current
100
+ // maximum, not a row count, so a gap left by {@link cleanup} deleting part
101
+ // of a Task cannot hand a later round an ordinal that is already taken.
102
+ // Both read the `(task_id, ordinal)` index; only this one stays monotonic.
135
103
  const highest = db
136
104
  .select({ max: max(subtasks.ordinal) })
137
105
  .from(subtasks)
@@ -139,8 +107,7 @@ export function makeSubtasks(db, opts) {
139
107
  .get()?.max ?? null;
140
108
  const firstOrdinal = highest === null ? 0 : highest + 1;
141
109
  drafts.forEach((d, offset) => {
142
- const { id } = db
143
- .insert(subtasks)
110
+ db.insert(subtasks)
144
111
  .values({
145
112
  taskId,
146
113
  round,
@@ -150,7 +117,6 @@ export function makeSubtasks(db, opts) {
150
117
  recipeVersion: null,
151
118
  prompt: d.prompt,
152
119
  referencesJson: JSON.stringify(d.references),
153
- dependsOnJson: "[]",
154
120
  paramsJson: JSON.stringify(d.params ?? {}),
155
121
  status: "pending",
156
122
  resultPartsJson: null,
@@ -159,20 +125,8 @@ export function makeSubtasks(db, opts) {
159
125
  updatedAt: now,
160
126
  completedAt: null
161
127
  })
162
- .returning({ id: subtasks.id })
163
- .get();
164
- keyToId.set(d.localKey, id);
165
- });
166
- // Pass 2: rewrite dependency edges to real ids.
167
- for (const d of drafts) {
168
- if (d.dependsOn.length === 0)
169
- continue;
170
- const ids = d.dependsOn.map((k) => keyToId.get(k));
171
- db.update(subtasks)
172
- .set({ dependsOnJson: JSON.stringify(ids) })
173
- .where(eq(subtasks.id, keyToId.get(d.localKey)))
174
128
  .run();
175
- }
129
+ });
176
130
  return listRound(taskId, round);
177
131
  });
178
132
  },
@@ -186,9 +140,10 @@ export function makeSubtasks(db, opts) {
186
140
  return list(taskId);
187
141
  },
188
142
  /**
189
- * List one round's Subtasks, in ordinal order — the scheduler's view. Phase 2
190
- * drives a single round's DAG at a time, so it must not see a sibling round's
191
- * rows; composition, by contrast, reads {@link list} across all rounds.
143
+ * List one round's Subtasks, in ordinal order — what the Workflow's `scan`
144
+ * projects. It executes a single round at a time, so it must not see a
145
+ * sibling round's rows; a later round, by contrast, reads {@link list}
146
+ * across all rounds to reunite each `delegate` call with its results.
192
147
  */
193
148
  listRound(taskId, round) {
194
149
  return listRound(taskId, round);
@@ -227,11 +182,10 @@ export function makeSubtasks(db, opts) {
227
182
  * Both sides are reachable and both must land. A child's failed result arrives
228
183
  * on a `running` row. The Workflow's last resort — `failSubtask`, once
229
184
  * `execute:<id>` has exhausted every retry — can arrive on either:
230
- * `executeSubtask` may throw before its `pending -> running` claim (a
231
- * dependency-invariant fault) or after it (a transient child fault). Leaving
232
- * either behind strands the row: a `pending` node re-enters the next wave
233
- * forever, and a `running` node blocks its dependents, which {@link skip} only
234
- * propagates past *failed* prerequisites.
185
+ * `executeSubtask` may throw before its `pending -> running` claim (an
186
+ * unresolvable recipe) or after it (a transient child fault). Leaving either
187
+ * behind strands the row in a non-terminal state that nobody is coming back
188
+ * to resolve.
235
189
  *
236
190
  * Returns false once the row is terminal — a late loser to the real result.
237
191
  */
@@ -242,10 +196,6 @@ export function makeSubtasks(db, opts) {
242
196
  completedAt: Date.now()
243
197
  });
244
198
  },
245
- /** Skip a Subtask blocked by a failed/skipped dependency: guarded `pending -> skipped`. */
246
- skip(id) {
247
- return transition(id, "pending", { status: "skipped" });
248
- },
249
199
  /**
250
200
  * Discard a late result after parent cancellation: guarded
251
201
  * `running -> canceled`. The parent calls this when a child returned a
@@ -264,12 +214,19 @@ export function makeSubtasks(db, opts) {
264
214
  * Running Subtasks are left alone here — the parent transitions those with
265
215
  * {@link cancelRunning} once their in-flight result comes back and is
266
216
  * discarded. Returns the number canceled.
217
+ *
218
+ * Keyed on the Task rather than one id, so this is the one guarded
219
+ * transition that cannot go through {@link transition} and sets its own
220
+ * timestamps. `completedAt` is part of that and not optional: every other
221
+ * terminal write records it, and a terminal row without one reads as still
222
+ * in flight to anything measuring how long a Subtask took or when a Task
223
+ * actually stopped.
267
224
  */
268
225
  cancelPending(taskId) {
269
226
  const now = Date.now();
270
227
  const canceled = db
271
228
  .update(subtasks)
272
- .set({ status: "canceled", updatedAt: now })
229
+ .set({ status: "canceled", updatedAt: now, completedAt: now })
273
230
  .where(and(eq(subtasks.taskId, taskId), eq(subtasks.status, "pending")))
274
231
  .returning({ id: subtasks.id })
275
232
  .all();
@@ -164,8 +164,8 @@ export declare const notifyTasks: import("drizzle-orm/sqlite-core").SQLiteTableW
164
164
  *
165
165
  * The integer primary key assigns a caller-local, monotonically increasing
166
166
  * `SubtaskId` (autoincrement, so ids are never reused after cleanup deletes
167
- * rows). References, dependency edges, and result parts are stored as JSON text
168
- * and parsed back into the `Subtask` contract by `models/subtasks.ts`.
167
+ * rows). References and result parts are stored as JSON text and parsed back
168
+ * into the `Subtask` contract by `models/subtasks.ts`.
169
169
  * `recipe_id`/`recipe_version` are null until execution starts, then record the
170
170
  * resolved recipe after the fact.
171
171
  */
@@ -336,25 +336,6 @@ export declare const subtasks: import("drizzle-orm/sqlite-core").SQLiteTableWith
336
336
  }, {}, {
337
337
  length: number | undefined;
338
338
  }>;
339
- dependsOnJson: import("drizzle-orm/sqlite-core").SQLiteColumn<{
340
- name: "depends_on_json";
341
- tableName: "subtasks";
342
- dataType: "string";
343
- columnType: "SQLiteText";
344
- data: string;
345
- driverParam: string;
346
- notNull: true;
347
- hasDefault: false;
348
- isPrimaryKey: false;
349
- isAutoincrement: false;
350
- hasRuntimeDefault: false;
351
- enumValues: [string, ...string[]];
352
- baseColumn: never;
353
- identity: undefined;
354
- generated: undefined;
355
- }, {}, {
356
- length: number | undefined;
357
- }>;
358
339
  paramsJson: import("drizzle-orm/sqlite-core").SQLiteColumn<{
359
340
  name: "params_json";
360
341
  tableName: "subtasks";
package/dist/db/schema.js CHANGED
@@ -51,8 +51,8 @@ export const notifyTasks = sqliteTable("notify_tasks", {
51
51
  *
52
52
  * The integer primary key assigns a caller-local, monotonically increasing
53
53
  * `SubtaskId` (autoincrement, so ids are never reused after cleanup deletes
54
- * rows). References, dependency edges, and result parts are stored as JSON text
55
- * and parsed back into the `Subtask` contract by `models/subtasks.ts`.
54
+ * rows). References and result parts are stored as JSON text and parsed back
55
+ * into the `Subtask` contract by `models/subtasks.ts`.
56
56
  * `recipe_id`/`recipe_version` are null until execution starts, then record the
57
57
  * resolved recipe after the fact.
58
58
  */
@@ -71,8 +71,6 @@ export const subtasks = sqliteTable("subtasks", {
71
71
  prompt: text("prompt").notNull(),
72
72
  /** JSON `SubtaskReference[]` — verbatim role+text snapshots from decomposition. */
73
73
  referencesJson: text("references_json").notNull(),
74
- /** JSON `SubtaskId[]` — resolved dependency edges. */
75
- dependsOnJson: text("depends_on_json").notNull(),
76
74
  /** JSON `SubtaskParams` — the type's required inputs, validated at delegation. */
77
75
  paramsJson: text("params_json").notNull().default("{}"),
78
76
  status: text("status").notNull(),
@@ -2,7 +2,7 @@ import { Agent, type Schedule } from "agents";
2
2
  import { type Task } from "@a2a-js/sdk";
3
3
  import { type AgentRuntime } from "../runtime/index.js";
4
4
  import type { AgentPlugin } from "../contract/plugin.js";
5
- import { type CoreConfig, type CoreConfigOverrides } from "../config.js";
5
+ import { type CoreConfig, type CoreConfigOverrides, type ModelConfig } from "../config.js";
6
6
  import type { A2ASecretsEnv, AiEnv } from "../env.js";
7
7
  import { AgentDB } from "../db/index.js";
8
8
  import type { GatewayIdentity } from "../a2a/verify.js";
@@ -10,7 +10,7 @@ import type { PlainTask } from "../a2a/task.js";
10
10
  import type { TaskListQuery } from "../a2a/agent-stub.js";
11
11
  import { type PushChannel, type TurnPushContext } from "../a2a/push.js";
12
12
  import { type SessionLike } from "../agent/session.js";
13
- import { type GatewayMetadata, type ModelPair, type ModelRuntime } from "../agent/model.js";
13
+ import type { GatewayMetadata, ModelPair, ModelRuntime } from "../agent/model.js";
14
14
  import type { PluginHost } from "./plugin-host.js";
15
15
  /**
16
16
  * The Durable Object body every Looping agent has, whatever loop it runs.
@@ -64,6 +64,9 @@ export declare abstract class LoopingAgent<TEnv extends Cloudflare.Env & AiEnv &
64
64
  * `onStart` runs before any request, so it is not known when `agentPlugins()`
65
65
  * is built — which is why anything per-caller takes a thunk. The DO is keyed
66
66
  * 1:1 by this value, so it is constant once set.
67
+ *
68
+ * In-memory, and deliberately so — see {@link requireIdentityKey}, which does
69
+ * not depend on it surviving.
67
70
  */
68
71
  private identityKey?;
69
72
  /**
@@ -95,7 +98,30 @@ export declare abstract class LoopingAgent<TEnv extends Cloudflare.Env & AiEnv &
95
98
  protected get config(): CoreConfig;
96
99
  /** The agent's database (drizzle + migrations), built once per DO instance. */
97
100
  protected get db(): AgentDB;
98
- /** The model runtime for this instance, built lazily over the `AI` binding. */
101
+ /**
102
+ * Which provider this agent's loops run on. Defaults to Workers AI; override
103
+ * to run on something else.
104
+ *
105
+ * The seam is here rather than in `models` because `models` memoizes, and a
106
+ * subclass overriding a memoized getter has to remember to keep the caching —
107
+ * a trap that only shows up as a performance bug. This is called once.
108
+ *
109
+ * `ModelRuntime` is the whole contract: return anything satisfying it and
110
+ * every loop in core keeps working unchanged. Core ships two implementations,
111
+ * one directory each — {@link file://../agent/workers-ai/index.ts
112
+ * `agent/workers-ai`} (the default below) and `@loopingai/core/anthropic` —
113
+ * and a third provider is a third directory exporting one
114
+ * {@link file://../agent/model.ts ModelRuntimeFactory}, not a change to
115
+ * anything on this path.
116
+ *
117
+ * Takes the resolved {@link ModelConfig} rather than reading `this.config`, so
118
+ * that this signature matches
119
+ * {@link file://../round/subagent.ts RecipeSubagentHost.modelRuntime} — an
120
+ * agent and its facet **must** run the same provider, and identical seams are
121
+ * what let one factory serve both instead of two hand-copied bodies.
122
+ */
123
+ protected modelRuntime(model: ModelConfig): ModelRuntime;
124
+ /** The model runtime for this instance, built lazily and memoized. */
99
125
  protected get models(): ModelRuntime;
100
126
  /**
101
127
  * What this agent's plugins are handed. Built from
@@ -141,7 +167,35 @@ export declare abstract class LoopingAgent<TEnv extends Cloudflare.Env & AiEnv &
141
167
  * announces the loss, and the runtime fans it out to every plugin that asked.
142
168
  */
143
169
  getSession(identity: GatewayIdentity): SessionLike;
144
- /** The caller key, which is present on every path that can reach a plugin. */
170
+ /**
171
+ * The caller key, which is present on every path that can reach a plugin.
172
+ *
173
+ * ## Why this does not just read the field
174
+ *
175
+ * `identityKey` is set on the first turn and lives in the isolate. An isolate
176
+ * does not live as long as the work does: it can be evicted between two rounds
177
+ * of the same task, and it can be reset outright — "Durable Object connection
178
+ * closed because the object was reset" — while a Workflow step is mid-flight.
179
+ * The next call arrives on a fresh instance where the field is empty, and
180
+ * every per-caller thunk built off it throws.
181
+ *
182
+ * That failure is disproportionate to its cause. A plugin asking which caller
183
+ * it is serving gets an exception, mid-task, on an object whose entire purpose
184
+ * is to be that caller's — and because the throw happens inside a tool or a
185
+ * runtime resolution rather than at the edge, it surfaces as a failed branch
186
+ * rather than as anything an operator can read.
187
+ *
188
+ * So the object answers from itself. `define-agent` routes with
189
+ * `ns.get(ns.idFromName(identity.key))`, which means the caller key *is* this
190
+ * object's name and the platform hands it back on `ctx.id.name` — durable, free
191
+ * and correct by construction: an object cannot disagree with the name it was
192
+ * addressed by.
193
+ *
194
+ * The field still wins when it is set. `id.name` is undefined for an object
195
+ * addressed by `newUniqueId()` or a raw id string, so it is a fallback rather
196
+ * than the source of truth, and the throw is kept for the case where neither
197
+ * exists.
198
+ */
145
199
  protected requireIdentityKey(): string;
146
200
  /** The gateway callback channel for one turn. See {@link PushChannel}. */
147
201
  protected push(context: TurnPushContext): PushChannel;