@intentface/latch-core 0.11.0 → 0.13.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/dist/runtime.js CHANGED
@@ -504,6 +504,23 @@ function fireAndForget(fn) {
504
504
  */
505
505
  /** Keys whose values are masked when a non-`Error` throw is serialized. */
506
506
  const SECRETISH_KEY = /token|secret|password|authorization|cookie|api[-_]?key|credential/i;
507
+ /** The last `error` chunk's text in a UI message stream body (SSE `data:` lines), if any. */
508
+ function lastStreamError(body) {
509
+ let error;
510
+ for (const line of body.split("\n")) {
511
+ if (!line.startsWith("data: "))
512
+ continue;
513
+ try {
514
+ const chunk = JSON.parse(line.slice(6));
515
+ if (chunk.type === "error" && chunk.errorText)
516
+ error = chunk.errorText;
517
+ }
518
+ catch {
519
+ // "[DONE]" and other non-JSON lines
520
+ }
521
+ }
522
+ return error;
523
+ }
507
524
  export function clientErrorMessage(error) {
508
525
  const message = error instanceof Error ? error.message.trim() : "";
509
526
  if (message)
@@ -554,13 +571,15 @@ export function createRuntime(config) {
554
571
  "Implement openTurn — see the StorageAdapter contract.");
555
572
  }
556
573
  /** Resolve an agent's per-request config + a display model id. */
557
- async function resolveAgentConfig(agentName, principal, request, turnContext) {
574
+ async function resolveAgentConfig(agentName, principal, request, turnContext,
575
+ /** Passed through to `dynamicAgents.resolve` (see DynamicAgentResolveContext). */
576
+ resolveCtx) {
558
577
  const runtimeCtx = await config.context.build({ principal, request, turnContext });
559
578
  // Code-declared agents first; otherwise a dynamic (e.g. DB-stored) agent.
560
579
  const factory = config.agents[agentName];
561
580
  const cfg = factory
562
581
  ? await factory({ context: runtimeCtx, principal, turnContext })
563
- : await config.dynamicAgents?.resolve(agentName, principal);
582
+ : await config.dynamicAgents?.resolve(agentName, principal, resolveCtx);
564
583
  if (!cfg)
565
584
  throw new Error(`Unknown agent: ${agentName}`);
566
585
  const modelId = typeof cfg.model === "string"
@@ -576,7 +595,16 @@ export function createRuntime(config) {
576
595
  * server-side (resume).
577
596
  */
578
597
  async function buildTurn(args) {
579
- const { resolvedChatId, principal, modelId, cfg, run, lease, runtimeCtx } = args;
598
+ const { resolvedChatId, principal, run, lease, runtimeCtx } = args;
599
+ // A per-turn model override replaces the agent's own, and the telemetry id
600
+ // with it — a run row that says the agent's model while a different one
601
+ // answered makes every later comparison a lie.
602
+ const cfg = args.model ? { ...args.cfg, model: args.model } : args.cfg;
603
+ const modelId = args.model
604
+ ? typeof args.model === "string"
605
+ ? args.model
606
+ : (args.model.modelId ?? "unknown")
607
+ : args.modelId;
580
608
  // Mutable: the start moments below may inject, replace or rewrite history
581
609
  // before the model runs. From here on this is the turn's transcript.
582
610
  let uiMessages = args.uiMessages;
@@ -777,7 +805,8 @@ export function createRuntime(config) {
777
805
  // append the provider's compiled-index block to the instructions. A
778
806
  // provider failure degrades to a turn without memory, never a dead turn.
779
807
  let instructions = cfg.instructions;
780
- if (cfg.memory && config.memory) {
808
+ const memoryOn = args.memory ?? true;
809
+ if (memoryOn && cfg.memory && config.memory) {
781
810
  const memArgs = { principal, agent: run.agent, memory: cfg.memory };
782
811
  try {
783
812
  // Resolve both hooks BEFORE mutating the toolset, so a failure in
@@ -890,7 +919,13 @@ export function createRuntime(config) {
890
919
  depth: depth + 1,
891
920
  autoApprove,
892
921
  blockGated,
922
+ // Memory off stays off down the tree: an eval turn's delegate must
923
+ // not recall earlier inputs or save new memory either.
924
+ ...(memoryOn ? {} : { memory: false }),
893
925
  onProgress,
926
+ // Who is delegating — persisted on the sub-thread so every later
927
+ // resolve of it can be shaped per parent (DynamicAgentResolveContext).
928
+ parentAgent: run.agent,
894
929
  // Telemetry linkage: this turn is the parent; carry the root chat
895
930
  // down so the subagent's trace nests under this conversation.
896
931
  parentRunId: run.id,
@@ -963,18 +998,36 @@ export function createRuntime(config) {
963
998
  // Test runs: stub the approval-gated (mutating) tools so a trial has no
964
999
  // real side effects. Gated set = connection defaults + the agent's policy.
965
1000
  if (blockGated) {
1001
+ /**
1002
+ * Does this approval value actually gate the tool?
1003
+ *
1004
+ * NOT a truthiness check. The SDK spells a waiver as the STRING
1005
+ * `"not-applicable"` — which is truthy — so `if (v)` treated every
1006
+ * tool an agent had explicitly waived as gated and stubbed it. On an
1007
+ * agent whose reads are waived to override a connection default, that
1008
+ * is every read tool: the agent could not retrieve anything, and an
1009
+ * eval run measured a model with no access to its own corpus.
1010
+ *
1011
+ * Gated: `true`, `"user-approval"`, an object (approval with a
1012
+ * reason), a function. Waived: `false`, `undefined`,
1013
+ * `"not-applicable"`.
1014
+ */
1015
+ const gatedBy = (v) => v === true ||
1016
+ v === "user-approval" ||
1017
+ typeof v === "function" ||
1018
+ (typeof v === "object" && v !== null);
966
1019
  const gated = new Set();
967
1020
  for (const [k, v] of Object.entries(harnessApproval))
968
- if (v)
1021
+ if (gatedBy(v))
969
1022
  gated.add(k);
970
1023
  for (const o of opened) {
971
1024
  for (const [k, v] of Object.entries(o.toolApproval ?? {}))
972
- if (v)
1025
+ if (gatedBy(v))
973
1026
  gated.add(k);
974
1027
  }
975
1028
  if (cfg.toolApproval && typeof cfg.toolApproval !== "function") {
976
1029
  for (const [k, v] of Object.entries(cfg.toolApproval)) {
977
- if (v)
1030
+ if (gatedBy(v))
978
1031
  gated.add(k);
979
1032
  else
980
1033
  gated.delete(k);
@@ -992,12 +1045,19 @@ export function createRuntime(config) {
992
1045
  if (t && typeof t.execute === "function") {
993
1046
  tools[name] = {
994
1047
  ...t,
995
- execute: async (input) => ({
996
- __blocked: true,
997
- tool: name,
998
- input,
999
- reason: "approval-gated tool blocked during test run (no real side effects)",
1000
- }),
1048
+ execute: async (input) => blockGated === "simulate-success"
1049
+ ? {
1050
+ ok: true,
1051
+ __stubbed: true,
1052
+ tool: name,
1053
+ input,
1054
+ }
1055
+ : {
1056
+ __blocked: true,
1057
+ tool: name,
1058
+ input,
1059
+ reason: "approval-gated tool blocked during test run (no real side effects)",
1060
+ },
1001
1061
  };
1002
1062
  }
1003
1063
  }
@@ -1130,7 +1190,7 @@ export function createRuntime(config) {
1130
1190
  : config.resolvePromptCaching
1131
1191
  ? config.resolvePromptCaching(modelId, {
1132
1192
  agent: run.agent,
1133
- memoryScoped: !!(cfg.memory && config.memory),
1193
+ memoryScoped: !!(memoryOn && cfg.memory && config.memory),
1134
1194
  principal,
1135
1195
  })
1136
1196
  : // No hook → caching is ON by default for first-party Anthropic /
@@ -1860,7 +1920,10 @@ export function createRuntime(config) {
1860
1920
  });
1861
1921
  }
1862
1922
  const resolvedChatId = chat.id;
1863
- const { cfg, modelId, runtimeCtx } = await resolveAgentConfig(agentName, principal, request, turnContext);
1923
+ const { cfg, modelId, runtimeCtx } = await resolveAgentConfig(agentName, principal, request, turnContext,
1924
+ // A host may continue a subagent thread through the chat surface; keep
1925
+ // resolving it the way its spawner would.
1926
+ { parentAgent: chat.parentAgent });
1864
1927
  const prior = await storage.loadMessages(principal, resolvedChatId);
1865
1928
  // A new message while the last turn is parked on an approval is itself the
1866
1929
  // decision: the user declined to answer and wants to steer elsewhere. Close
@@ -1902,11 +1965,38 @@ export function createRuntime(config) {
1902
1965
  return null;
1903
1966
  // System op (no request): rebuild the principal from the run's identity blob.
1904
1967
  const principal = await reconstructPrincipal(run.identity);
1905
- const uiMessages = await storage.loadMessages(principal, run.chatId);
1906
1968
  const lease = {
1907
1969
  owner: dur.instanceId,
1908
1970
  fencingToken: run.fencingToken ?? 1,
1909
1971
  };
1972
+ if (principal === null) {
1973
+ // Refused (see `ReconstructPrincipal`): the identity may no longer act, so
1974
+ // nothing below — not even reading its history — runs as it. End the run
1975
+ // instead of returning early with the lease held: an `active` row would be
1976
+ // reaped and re-refused every tick until `maxAttempts`, and a throw here
1977
+ // would abort the rest of `sweep`'s loop. No `onRunFinished`: its meta
1978
+ // carries the principal, and there is none to report.
1979
+ const error = "principal rejected: reconstructPrincipal refused the run's identity";
1980
+ await storage.updateRun(run.id, {
1981
+ status: "errored",
1982
+ error,
1983
+ endedAt: Date.now(),
1984
+ // Terminal, so give up the reclaim just taken. `reclaimRun` matches
1985
+ // any non-completed row whose lease has lapsed, so a lease left to
1986
+ // expire would let a direct `resume()` re-claim and re-refuse it,
1987
+ // spending an attempt each time.
1988
+ leaseOwner: null,
1989
+ leaseExpiresAt: null,
1990
+ }, lease);
1991
+ console.warn(`[resume] run ${run.id} (${run.agent}): ${error}`);
1992
+ const settled = await storage.getRun(runId);
1993
+ return {
1994
+ runId,
1995
+ status: settled?.status ?? "errored",
1996
+ attempt: settled?.attempt ?? run.attempt ?? null,
1997
+ };
1998
+ }
1999
+ const uiMessages = await storage.loadMessages(principal, run.chatId);
1910
2000
  // What does this run actually need? A crash between the last step's
1911
2001
  // checkpoint and `onFinish`'s `updateRun` leaves a FINISHED turn on an
1912
2002
  // `active` row, and re-running the model there would pay twice for an answer
@@ -2001,7 +2091,12 @@ export function createRuntime(config) {
2001
2091
  // reconcilable even if its (dynamic, DB-stored) agent was since deleted.
2002
2092
  // Before this ordering that throw also aborted `sweep`'s loop, so one such
2003
2093
  // run held up recovery for every other run until its attempts ran out.
2004
- const { cfg, modelId, runtimeCtx } = await resolveAgentConfig(run.agent, principal);
2094
+ // A recovered subagent run must resolve exactly as its spawner shaped it —
2095
+ // crash recovery and `sweep` both land here.
2096
+ const chat = await storage.getChat(principal, run.chatId);
2097
+ const { cfg, modelId, runtimeCtx } = await resolveAgentConfig(run.agent, principal, undefined, undefined, {
2098
+ parentAgent: chat?.parentAgent,
2099
+ });
2005
2100
  // A resume was admitted when the turn first ran — never refuse it (the
2006
2101
  // half-done work would strand), but its tokens still settle against the
2007
2102
  // same counters, and the mid-turn stop still applies.
@@ -2046,16 +2141,31 @@ export function createRuntime(config) {
2046
2141
  // Give up on poison runs that keep dying — don't loop forever.
2047
2142
  if ((run.attempt ?? 1) >= maxAttempts)
2048
2143
  continue;
2049
- const outcome = await resumeRun(run.id);
2050
- if (outcome)
2051
- resumed.push(outcome);
2144
+ // One run's failure must not strand the rest of the batch. The reaper
2145
+ // flipped EVERY run here to `errored` in one write, and no later reap
2146
+ // selects an `errored` row — so an escaping throw (a transient
2147
+ // `reconstructPrincipal` failure, a storage blip) would leave each run
2148
+ // after it unrecovered for good. A throw AFTER the reclaim (the hook,
2149
+ // history, the turn) leaves that run `active` under a lease, so it is
2150
+ // reaped and retried once the lease lapses, spending an attempt like any
2151
+ // other failed resume. A throw FROM the reclaim itself leaves the run as
2152
+ // the reaper left it — `errored`, not auto-retried — which is today's
2153
+ // outcome for that run too; only its neighbours are new to recovery.
2154
+ try {
2155
+ const outcome = await resumeRun(run.id);
2156
+ if (outcome)
2157
+ resumed.push(outcome);
2158
+ }
2159
+ catch (e) {
2160
+ console.error(`[resume] run ${run.id} (${run.agent}) failed; continuing the sweep:`, e);
2161
+ }
2052
2162
  }
2053
2163
  return { reaped, resumed };
2054
2164
  }
2055
2165
  async function cron(opts) {
2056
2166
  const errors = [];
2057
2167
  const describe = (e) => (e instanceof Error ? e.message : String(e));
2058
- let due = { fired: 0, errors: 0, skipped: 0, parked: 0 };
2168
+ let due = { fired: 0, errors: 0, skipped: 0, parked: 0, rejected: 0 };
2059
2169
  try {
2060
2170
  due = await runDue({ now: opts?.now });
2061
2171
  }
@@ -2150,7 +2260,7 @@ export function createRuntime(config) {
2150
2260
  if (hasPendingApproval(messages)) {
2151
2261
  return new Response(null, { status: 200 });
2152
2262
  }
2153
- const { cfg, modelId, runtimeCtx } = await resolveAgentConfig(chat.agent, principal, request, turnContext);
2263
+ const { cfg, modelId, runtimeCtx } = await resolveAgentConfig(chat.agent, principal, request, turnContext, { parentAgent: chat.parentAgent });
2154
2264
  const admitted = await admitContinuation(principal, chat.agent, "decision");
2155
2265
  const { run, lease } = await openRun(principal, chatId, chat.agent, cfg.configVersion);
2156
2266
  return buildTurn({
@@ -2198,7 +2308,7 @@ export function createRuntime(config) {
2198
2308
  await storage.appendMessages(chatId, changed);
2199
2309
  return new Response(null, { status: 200 });
2200
2310
  }
2201
- const { cfg, modelId, runtimeCtx } = await resolveAgentConfig(chat.agent, principal, request, turnContext);
2311
+ const { cfg, modelId, runtimeCtx } = await resolveAgentConfig(chat.agent, principal, request, turnContext, { parentAgent: chat.parentAgent });
2202
2312
  const admitted = await admitContinuation(principal, chat.agent, "decision");
2203
2313
  // The result is persisted WITH the run (atomically where the adapter can):
2204
2314
  // a busy chat refuses before anything is recorded, so the retry finds the
@@ -2244,7 +2354,7 @@ export function createRuntime(config) {
2244
2354
  if (hasPendingApproval(live) || hasPendingToolResult([live[live.length - 1]])) {
2245
2355
  throw new PendingApprovalError("This chat is awaiting a decision — resolve the pending request before compacting.");
2246
2356
  }
2247
- const { cfg, modelId } = await resolveAgentConfig(chat.agent, principal, request, turnContext);
2357
+ const { cfg, modelId } = await resolveAgentConfig(chat.agent, principal, request, turnContext, { parentAgent: chat.parentAgent });
2248
2358
  // Tool definitions only affect how a recorded tool RESULT is rendered for the
2249
2359
  // model (`toModelOutput`); an unknown tool falls back to its raw JSON rather
2250
2360
  // than failing. So take every definition available without I/O — the agent's
@@ -2377,11 +2487,14 @@ export function createRuntime(config) {
2377
2487
  return chat ? run : null;
2378
2488
  }
2379
2489
  /** A fresh user message from plain text (for cron-fired turns). */
2380
- function userMessageOf(text) {
2490
+ function userMessageOf(text, files) {
2381
2491
  return {
2382
2492
  id: generateMessageId(),
2383
2493
  role: "user",
2384
- parts: [{ type: "text", text }],
2494
+ parts: [
2495
+ ...(files ?? []).map((f) => ({ type: "file", url: f.url, mediaType: f.mediaType, ...(f.filename ? { filename: f.filename } : {}) })),
2496
+ { type: "text", text },
2497
+ ],
2385
2498
  metadata: { visibility: "user", createdAt: Date.now() },
2386
2499
  };
2387
2500
  }
@@ -2443,8 +2556,15 @@ export function createRuntime(config) {
2443
2556
  // Runtime-created thread: stamp it internal so a host filtering its
2444
2557
  // chat list on kind never shows it (see ChatRecord.kind).
2445
2558
  kind: "internal",
2559
+ ...(o.parentAgent ? { parentAgent: o.parentAgent } : {}),
2446
2560
  });
2447
2561
  }
2562
+ // A spawn may only continue a thread its own agent delegated. Otherwise
2563
+ // agent B could pass agent A's sub-thread id and have the child resolved
2564
+ // under A — borrowing whatever a host grants A's subagents.
2565
+ if (o.parentAgent && chat.parentAgent && chat.parentAgent !== o.parentAgent) {
2566
+ throw new Error(`Thread ${chatId} was delegated by another agent; omit threadId to start a new one.`);
2567
+ }
2448
2568
  const prior = await storage.loadMessages(o.principal, chatId);
2449
2569
  // Same rule as handleChat: a new message while the thread is parked on an
2450
2570
  // approval IS the decision — close the stale gate as declined (persisted)
@@ -2461,7 +2581,14 @@ export function createRuntime(config) {
2461
2581
  // the run first, persist the message only once it is ours to run.
2462
2582
  const admitted = await admitTurn(o.principal, o.agent, o.trigger);
2463
2583
  const { cfg, modelId, runtimeCtx, run, lease } = await withReservation(admitted, async () => {
2464
- const resolved = await resolveAgentConfig(o.agent, o.principal, undefined, o.turnContext);
2584
+ const resolved = await resolveAgentConfig(o.agent, o.principal, undefined, o.turnContext, {
2585
+ // A continued thread keeps the parent it was created under (a mismatch
2586
+ // was refused above). Without a stored one — an adapter that doesn't
2587
+ // persist the column — the spawner of THIS turn is the parent: a host
2588
+ // that restricts a subagent by its parent must never see none.
2589
+ parentAgent: chat.parentAgent ?? o.parentAgent,
2590
+ autoApprove: o.autoApprove,
2591
+ });
2465
2592
  const opened = await openTurn(o.principal, chatId, o.agent, [o.message], resolved.cfg.configVersion);
2466
2593
  return { ...resolved, ...opened };
2467
2594
  });
@@ -2478,6 +2605,8 @@ export function createRuntime(config) {
2478
2605
  admitted,
2479
2606
  autoApprove: o.autoApprove,
2480
2607
  blockGated: o.blockGated,
2608
+ memory: o.memory,
2609
+ model: o.model,
2481
2610
  promptCaching: o.promptCaching,
2482
2611
  depth: o.depth ?? 0,
2483
2612
  parentRunId: o.parentRunId,
@@ -2506,7 +2635,9 @@ export function createRuntime(config) {
2506
2635
  }
2507
2636
  : undefined,
2508
2637
  });
2509
- await res.text();
2638
+ // The turn's own failure travels as an `error` chunk — the stream, not a
2639
+ // throw. Keep its text: without it a failed turn reads as a silent one.
2640
+ const error = lastStreamError(await res.text());
2510
2641
  const after = await storage.loadMessages(o.principal, chatId);
2511
2642
  // The verdict is about THIS turn, so read the FINAL message only (the same
2512
2643
  // rule hasPendingApproval applies): an undecided gate in an older message
@@ -2521,6 +2652,7 @@ export function createRuntime(config) {
2521
2652
  answer: pending.length > 0 ? "" : lastAssistantText(after),
2522
2653
  parked,
2523
2654
  ...(pending.length > 0 ? { pending } : {}),
2655
+ ...(error ? { error } : {}),
2524
2656
  };
2525
2657
  }
2526
2658
  /**
@@ -2546,7 +2678,10 @@ export function createRuntime(config) {
2546
2678
  // Another gated call in the same sub step still undecided → can't finish.
2547
2679
  if (hasPendingApproval(subMessages))
2548
2680
  return { answer: "" };
2549
- const { cfg, modelId, runtimeCtx } = await resolveAgentConfig(subChat.agent, principal);
2681
+ const { cfg, modelId, runtimeCtx } = await resolveAgentConfig(subChat.agent, principal, undefined, undefined,
2682
+ // Same shaping as the first sub-turn: the parent is read off the persisted
2683
+ // sub-thread, and this path is only ever reached by a human decision.
2684
+ { parentAgent: subChat.parentAgent, autoApprove: false });
2550
2685
  const admitted = await admitContinuation(principal, subChat.agent, "decision");
2551
2686
  const { run, lease } = await openRun(principal, subChatId, subChat.agent, cfg.configVersion);
2552
2687
  const res = await buildTurn({
@@ -2609,8 +2744,28 @@ export function createRuntime(config) {
2609
2744
  let errors = 0;
2610
2745
  let skipped = 0;
2611
2746
  let parked = 0;
2747
+ let rejected = 0;
2612
2748
  for (const s of claimed) {
2613
2749
  const next = s.cron ? (config.cron?.(s.cron, now, s.timezone) ?? null) : null;
2750
+ // Rebuild the principal FIRST, because a refusal has to reach the one
2751
+ // write below: `completeSchedule` is fenced by the claim lease and clears
2752
+ // it, so there is no second write in which to disable the schedule. A
2753
+ // refused identity (`null`) is permanent — the owner was removed, the
2754
+ // account is gone — so the schedule is disabled rather than left to refuse
2755
+ // on every tick, which is how a revoked user's schedule stays invisible
2756
+ // forever. A THROW is transient and keeps today's contract: the occurrence
2757
+ // is still consumed, the fire is counted in `errors`, the schedule stays
2758
+ // live. Hosts keep the hook fast (see `reconstructPrincipal`); it now runs
2759
+ // inside the claim window.
2760
+ let principal = null;
2761
+ let principalError;
2762
+ try {
2763
+ principal = await reconstructPrincipal(s.identity);
2764
+ }
2765
+ catch (error) {
2766
+ principalError = { error };
2767
+ }
2768
+ const refused = !principalError && principal === null;
2614
2769
  // Consume the occurrence BEFORE firing, not after. The claim lease is
2615
2770
  // stamped once (never heartbeated), so while a fire is in flight the row
2616
2771
  // still reads `enabled AND nextRunAt <= now` — and once the lease TTL
@@ -2625,7 +2780,20 @@ export function createRuntime(config) {
2625
2780
  // survives and `sweep()` resumes it. For work that is billed per
2626
2781
  // attempt, "ran twice" is strictly worse than "ran once, resumed".
2627
2782
  try {
2628
- await storage.completeSchedule(s.id, { nextRunAt: next ?? now, lastRunAt: now, enabled: next != null }, dur.instanceId);
2783
+ const applied = await storage.completeSchedule(s.id, { nextRunAt: next ?? now, lastRunAt: now, enabled: next != null && !refused }, dur.instanceId,
2784
+ // Which claim this is: a later pass on this same instance may have
2785
+ // re-claimed the row under the same owner id since.
2786
+ s.leaseExpiresAt != null ? { leaseExpiresAt: s.leaseExpiresAt } : undefined);
2787
+ if (applied === false) {
2788
+ // The fence rejected us: our claim lapsed (the lease is never
2789
+ // renewed, and the hook above runs inside it) and a newer claim —
2790
+ // another node's, or a later pass on this one — holds this
2791
+ // occurrence. It fires it and accounts for it; firing here too would
2792
+ // be the duplicate paid turn consuming-first exists to prevent.
2793
+ console.warn(`[schedule] ${s.id} (${s.agent}): claim lost before the occurrence was consumed; ` +
2794
+ `the newer claim owns this fire`);
2795
+ continue;
2796
+ }
2629
2797
  }
2630
2798
  catch (e) {
2631
2799
  // Cannot consume the occurrence → do NOT fire. Firing anyway would
@@ -2635,8 +2803,18 @@ export function createRuntime(config) {
2635
2803
  errors++;
2636
2804
  continue;
2637
2805
  }
2806
+ if (principalError) {
2807
+ console.error(`[schedule] fire failed for ${s.id} (${s.agent}): reconstructPrincipal threw:`, principalError.error);
2808
+ errors++;
2809
+ continue;
2810
+ }
2811
+ if (principal === null) {
2812
+ console.warn(`[schedule] ${s.id} (${s.agent}): reconstructPrincipal refused the schedule's ` +
2813
+ `identity — not fired, schedule disabled`);
2814
+ rejected++;
2815
+ continue;
2816
+ }
2638
2817
  try {
2639
- const principal = await reconstructPrincipal(s.identity);
2640
2818
  // Parked-predecessor guard: when the LAST fire's chat is still waiting
2641
2819
  // on a human (an undecided approval or tool result), firing again just
2642
2820
  // mints another parked chat and pays for the tokens up to the gate —
@@ -2735,29 +2913,31 @@ export function createRuntime(config) {
2735
2913
  errors++;
2736
2914
  }
2737
2915
  }
2738
- return { fired, errors, skipped, parked };
2916
+ return { fired, errors, skipped, parked, rejected };
2739
2917
  }
2740
2918
  const self = {
2741
2919
  listAgents: agentInfos,
2742
2920
  handleChat,
2743
- runAgent: async ({ principal, agent, prompt, threadId, blockGated = true, turnContext, promptCaching,
2921
+ runAgent: async ({ principal, agent, prompt, files, threadId, blockGated = true, memory, model, turnContext, promptCaching,
2744
2922
  // No client stream: assume unattended unless the host says otherwise, so
2745
2923
  // a park here can't be silently filtered out as "someone's watching".
2746
2924
  trigger = "programmatic", }) => {
2747
2925
  const r = await runToCompletion({
2748
2926
  principal,
2749
2927
  agent,
2750
- message: userMessageOf(prompt),
2928
+ message: userMessageOf(prompt, files),
2751
2929
  chatId: threadId,
2752
2930
  depth: 1,
2753
2931
  // Autonomous: no human to authorize (blockGated stubs the gated tools).
2754
2932
  autoApprove: true,
2755
2933
  blockGated,
2934
+ memory,
2935
+ model,
2756
2936
  turnContext,
2757
2937
  promptCaching,
2758
2938
  trigger,
2759
2939
  });
2760
- return { threadId: r.chatId, answer: r.answer };
2940
+ return { threadId: r.chatId, answer: r.answer, ...(r.error ? { error: r.error } : {}) };
2761
2941
  },
2762
2942
  compactChat,
2763
2943
  loadHistory,