@electric-ax/agents 0.4.12 → 0.4.14

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/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { mergeElectricPrincipalHeader } from "./server-headers-KD5yHFYT.js";
2
2
  import path from "node:path";
3
3
  import { fileURLToPath } from "node:url";
4
- import { MOONSHOT_API_BASE_URL, MOONSHOT_PROVIDER, appendPathToUrl, completeWithLowCostModel, createEntityRegistry, createPullWakeRunner, createRuntimeHandler, createSkillTools, createSkillsRegistry, db, detectAvailableProviders, getMoonshotApiKey, getMoonshotModels, readCodexAccessToken, registerToolProvider, unregisterToolProvider } from "@electric-ax/agents-runtime";
4
+ import { MOONSHOT_API_BASE_URL, MOONSHOT_PROVIDER, appendPathToUrl, buildSkillSlashCommands, completeWithLowCostModel, createContextSkillLoader, createEntityRegistry, createPullWakeRunner, createRuntimeHandler, createSkillsRegistry, db, detectAvailableProviders, getMoonshotApiKey, getMoonshotModel, getMoonshotModels, readCodexAccessToken, registerToolProvider, unregisterToolProvider } from "@electric-ax/agents-runtime";
5
5
  import { braveSearchTool, braveSearchTool as braveSearchTool$1, createBashTool, createEditTool, createEventSourceTools, createFetchUrlTool, createReadFileTool, createSendTool, createWriteTool } from "@electric-ax/agents-runtime/tools";
6
6
  import { chooseDefaultSandbox, isE2BAvailable, remoteSandbox } from "@electric-ax/agents-runtime/sandbox";
7
7
  import fsSync from "node:fs";
@@ -15,6 +15,7 @@ import { load } from "sqlite-vec";
15
15
  import { nanoid } from "nanoid";
16
16
  import { getModels } from "@mariozechner/pi-ai";
17
17
  import { bridgeMcpTool, buildPromptTools, buildResourceTools, createRegistry, keychainPersistence, loadConfig, mcp, watchConfig } from "@electric-ax/agents-mcp";
18
+ import { Agent, cacheStores, interceptors, setGlobalDispatcher } from "undici";
18
19
 
19
20
  //#region src/log.ts
20
21
  const LOG_LEVEL = process.env.ELECTRIC_AGENTS_LOG_LEVEL ?? `info`;
@@ -865,6 +866,15 @@ async function fetchAvailableModelIds(provider) {
865
866
  function knownModelsForProvider(provider) {
866
867
  return provider === MOONSHOT_PROVIDER ? getMoonshotModels() : getModels(provider);
867
868
  }
869
+ function resolveBuiltinModelContextWindow(modelConfig) {
870
+ const modelId = String(modelConfig.model);
871
+ if (modelConfig.provider === MOONSHOT_PROVIDER) return getMoonshotModel(modelId)?.contextWindow ?? null;
872
+ if (!modelConfig.provider) return null;
873
+ return knownModelsForProvider(modelConfig.provider).find((model) => model.id === modelId)?.contextWindow ?? null;
874
+ }
875
+ function resolveBuiltinModelSourceBudget(modelConfig) {
876
+ return resolveBuiltinModelContextWindow(modelConfig) ?? 1e5;
877
+ }
868
878
  function choiceForKnownModel(provider, model) {
869
879
  return {
870
880
  provider,
@@ -968,6 +978,8 @@ function modelInputSchemaDefs(catalog) {
968
978
  const HORTON_MODEL = `claude-sonnet-4-6`;
969
979
  const TITLE_SYSTEM_PROMPT = "You generate concise chat session titles in 3-5 words. Respond with only the title, no quotes, no punctuation, no preamble.";
970
980
  const TITLE_USER_PROMPT = (userMessage) => `User request:\n${userMessage}`;
981
+ const TITLE_GENERATION_TIMEOUT_MS = 8e3;
982
+ const HORTON_SKILLS_SLASH_COMMAND_OWNER = `horton:skills`;
971
983
  const TITLE_STOP_WORDS = new Set([
972
984
  `a`,
973
985
  `an`,
@@ -1047,6 +1059,17 @@ function createConfiguredTitleCall(catalog, modelConfig, logPrefix) {
1047
1059
  maxTokens: 64
1048
1060
  });
1049
1061
  }
1062
+ function withTimeout(promise, ms, description) {
1063
+ let timeout;
1064
+ const timeoutPromise = new Promise((_resolve, reject) => {
1065
+ timeout = setTimeout(() => {
1066
+ reject(new Error(`${description} timed out after ${ms}ms`));
1067
+ }, ms);
1068
+ });
1069
+ return Promise.race([promise, timeoutPromise]).finally(() => {
1070
+ if (timeout) clearTimeout(timeout);
1071
+ });
1072
+ }
1050
1073
  async function generateTitle(userMessage, llmCall, onFallback) {
1051
1074
  try {
1052
1075
  const raw = await llmCall(TITLE_USER_PROMPT(userMessage));
@@ -1176,8 +1199,12 @@ function payloadToTitleText(payload) {
1176
1199
  if (typeof payload === `string`) return payload;
1177
1200
  if (payload == null) return ``;
1178
1201
  if (typeof payload === `object`) {
1179
- const text = payload.text;
1180
- return typeof text === `string` ? text : JSON.stringify(payload);
1202
+ const record = payload;
1203
+ const text = record.text;
1204
+ if (typeof text === `string`) return text;
1205
+ const source = record.source;
1206
+ if (typeof source === `string`) return source;
1207
+ return JSON.stringify(payload);
1181
1208
  }
1182
1209
  return String(payload);
1183
1210
  }
@@ -1235,10 +1262,13 @@ async function readAgentsMd(sandbox) {
1235
1262
  }
1236
1263
  function createAssistantHandler(options) {
1237
1264
  const { streamFn, docsSupport, docsSearchTool, skillsRegistry, modelCatalog, docsUrl } = options;
1238
- const hasSkills = Boolean(skillsRegistry && skillsRegistry.catalog.size > 0);
1265
+ const skillLoader = createContextSkillLoader(skillsRegistry, { slashCommandOwner: HORTON_SKILLS_SLASH_COMMAND_OWNER });
1266
+ const hasSkills = skillLoader.hasSkills;
1239
1267
  return async function assistantHandler(ctx, wake) {
1268
+ const loadedSkills = await skillLoader.load(ctx);
1240
1269
  const readSet = new Set();
1241
1270
  const modelConfig = resolveBuiltinModelConfig(modelCatalog, ctx.args);
1271
+ const sourceBudget = resolveBuiltinModelSourceBudget(modelConfig);
1242
1272
  const sandboxCwd = ctx.sandbox.workingDirectory;
1243
1273
  const agentsMd = await readAgentsMd(ctx.sandbox);
1244
1274
  const tools = [
@@ -1249,7 +1279,7 @@ function createAssistantHandler(options) {
1249
1279
  modelCatalog,
1250
1280
  logPrefix: `[horton ${ctx.entityUrl}]`
1251
1281
  }),
1252
- ...skillsRegistry && skillsRegistry.catalog.size > 0 ? createSkillTools(skillsRegistry, ctx) : [],
1282
+ ...loadedSkills.tools,
1253
1283
  ...mcp.tools()
1254
1284
  ];
1255
1285
  const hasEventSourceTools = tools.some((tool) => getToolName(tool) === `list_event_sources`);
@@ -1258,21 +1288,22 @@ function createAssistantHandler(options) {
1258
1288
  if (!firstUserMessage) return;
1259
1289
  let title = null;
1260
1290
  try {
1261
- const result = await generateTitle(firstUserMessage, createConfiguredTitleCall(modelCatalog, modelConfig, `[horton ${ctx.entityUrl}]`), (reason) => {
1291
+ const result = await generateTitle(firstUserMessage, (prompt) => withTimeout(createConfiguredTitleCall(modelCatalog, modelConfig, `[horton ${ctx.entityUrl}]`)(prompt), TITLE_GENERATION_TIMEOUT_MS, `title generation`), (reason) => {
1262
1292
  serverLog.warn(`[horton ${ctx.entityUrl}] title generation fell back to local title: ${reason}`);
1263
1293
  });
1264
1294
  if (result.length > 0) title = result;
1265
1295
  } catch (err) {
1266
1296
  serverLog.warn(`[horton ${ctx.entityUrl}] title generation failed: ${err instanceof Error ? err.message : String(err)}`);
1297
+ title = buildFallbackTitle(firstUserMessage);
1267
1298
  }
1268
1299
  if (title !== null) try {
1269
- await ctx.setTag(`title`, title);
1300
+ await withTimeout(ctx.setTag(`title`, title), TITLE_GENERATION_TIMEOUT_MS, `set title tag`);
1270
1301
  } catch (err) {
1271
1302
  serverLog.warn(`[horton ${ctx.entityUrl}] setTag failed: ${err instanceof Error ? err.message : String(err)}`);
1272
1303
  }
1273
1304
  })() : Promise.resolve();
1274
1305
  if (docsSupport) ctx.useContext({
1275
- sourceBudget: 1e5,
1306
+ sourceBudget,
1276
1307
  sources: {
1277
1308
  docs_toc: {
1278
1309
  content: () => docsSupport.renderCompressedToc(),
@@ -1293,21 +1324,13 @@ function createAssistantHandler(options) {
1293
1324
  max: 2e4,
1294
1325
  cache: `stable`
1295
1326
  } } : {},
1296
- ...skillsRegistry && skillsRegistry.catalog.size > 0 ? { skills_catalog: {
1297
- content: () => skillsRegistry.renderCatalog(2e3),
1298
- max: 2e3,
1299
- cache: `stable`
1300
- } } : {}
1327
+ ...skillsRegistry && skillsRegistry.catalog.size > 0 ? loadedSkills.sources : {}
1301
1328
  }
1302
1329
  });
1303
1330
  else if (skillsRegistry && skillsRegistry.catalog.size > 0) ctx.useContext({
1304
- sourceBudget: 1e5,
1331
+ sourceBudget,
1305
1332
  sources: {
1306
- skills_catalog: {
1307
- content: () => skillsRegistry.renderCatalog(2e3),
1308
- max: 2e3,
1309
- cache: `stable`
1310
- },
1333
+ ...loadedSkills.sources,
1311
1334
  conversation: {
1312
1335
  content: () => ctx.timelineMessages(),
1313
1336
  cache: `volatile`
@@ -1320,7 +1343,7 @@ function createAssistantHandler(options) {
1320
1343
  }
1321
1344
  });
1322
1345
  else if (agentsMd) ctx.useContext({
1323
- sourceBudget: 1e5,
1346
+ sourceBudget,
1324
1347
  sources: {
1325
1348
  conversation: {
1326
1349
  content: () => ctx.timelineMessages(),
@@ -1377,6 +1400,16 @@ function registerHorton(registry, options) {
1377
1400
  registry.define(`horton`, {
1378
1401
  description: `Friendly capable assistant — chat, code, research, dispatch`,
1379
1402
  creationSchema: hortonCreationSchema,
1403
+ permissionGrants: [{
1404
+ subject_kind: `principal_kind`,
1405
+ subject_value: `user`,
1406
+ permission: `spawn`
1407
+ }, {
1408
+ subject_kind: `principal_kind`,
1409
+ subject_value: `user`,
1410
+ permission: `manage`
1411
+ }],
1412
+ slashCommands: buildSkillSlashCommands(skillsRegistry),
1380
1413
  handler: assistantHandler
1381
1414
  });
1382
1415
  return [`horton`];
@@ -1418,7 +1451,7 @@ function parseWorkerArgs(value) {
1418
1451
  if (typeof value.reasoningEffort === `string` && REASONING_EFFORT_VALUES.includes(value.reasoningEffort)) args.reasoningEffort = value.reasoningEffort;
1419
1452
  return args;
1420
1453
  }
1421
- function buildToolsForWorker(tools, sandbox, ctx, readSet) {
1454
+ function buildToolsForWorker(tools, sandbox, ctx, readSet, opts) {
1422
1455
  const out = [];
1423
1456
  for (const name of tools) switch (name) {
1424
1457
  case `bash`:
@@ -1437,7 +1470,10 @@ function buildToolsForWorker(tools, sandbox, ctx, readSet) {
1437
1470
  out.push(braveSearchTool$1);
1438
1471
  break;
1439
1472
  case `fetch_url`:
1440
- out.push(createFetchUrlTool(sandbox));
1473
+ out.push(createFetchUrlTool(sandbox, {
1474
+ catalog: opts.modelCatalog,
1475
+ modelConfig: opts.modelConfig
1476
+ }));
1441
1477
  break;
1442
1478
  case `spawn_worker`:
1443
1479
  out.push(createSpawnWorkerTool(ctx));
@@ -1548,11 +1584,23 @@ function registerWorker(registry, options) {
1548
1584
  const { streamFn, modelCatalog } = options;
1549
1585
  registry.define(`worker`, {
1550
1586
  description: `Internal — generic worker spawned by other agents. Configure via spawn args (systemPrompt + tools + optional sharedDb).`,
1587
+ permissionGrants: [{
1588
+ subject_kind: `principal_kind`,
1589
+ subject_value: `user`,
1590
+ permission: `spawn`
1591
+ }, {
1592
+ subject_kind: `principal_kind`,
1593
+ subject_value: `user`,
1594
+ permission: `manage`
1595
+ }],
1551
1596
  async handler(ctx) {
1552
1597
  const args = parseWorkerArgs(ctx.args);
1553
1598
  const readSet = new Set();
1554
- const builtinTools = buildToolsForWorker(args.tools, ctx.sandbox, ctx, readSet);
1555
1599
  const modelConfig = resolveBuiltinModelConfig(modelCatalog, args);
1600
+ const builtinTools = buildToolsForWorker(args.tools, ctx.sandbox, ctx, readSet, {
1601
+ modelCatalog,
1602
+ modelConfig
1603
+ });
1556
1604
  const sharedStateTools = [];
1557
1605
  if (args.sharedDb) {
1558
1606
  const shared = await ctx.observe(db(args.sharedDb.id, args.sharedDb.schema));
@@ -1746,6 +1794,18 @@ function resolveCwd(args, fallback) {
1746
1794
  return readWorkingDirectoryArg(args) ?? fallback;
1747
1795
  }
1748
1796
 
1797
+ //#endregion
1798
+ //#region src/durable-streams-cache.ts
1799
+ const MEMORY_CACHE_SIZE_BYTES = 100 * 1024 * 1024;
1800
+ function installDurableStreamsFetchCache(options = {}) {
1801
+ if (options === false) return;
1802
+ const store = options.store === `sqlite` || options.sqliteLocation ? new cacheStores.SqliteCacheStore({
1803
+ location: options.sqliteLocation,
1804
+ maxCount: options.maxCount
1805
+ }) : new cacheStores.MemoryCacheStore({ maxSize: MEMORY_CACHE_SIZE_BYTES });
1806
+ setGlobalDispatcher(new Agent().compose(interceptors.cache({ store })));
1807
+ }
1808
+
1749
1809
  //#endregion
1750
1810
  //#region src/server.ts
1751
1811
  var BuiltinAgentsServer = class {
@@ -1755,6 +1815,8 @@ var BuiltinAgentsServer = class {
1755
1815
  mcpToolProviderName = null;
1756
1816
  mcpApplyInFlight = new Set();
1757
1817
  mcpStopping = false;
1818
+ mcpExtras = [];
1819
+ mcpLastJsonConfig = null;
1758
1820
  pullWakeRunner = null;
1759
1821
  options;
1760
1822
  constructor(options) {
@@ -1764,8 +1826,70 @@ var BuiltinAgentsServer = class {
1764
1826
  get mcpRegistry() {
1765
1827
  return this._mcpRegistry;
1766
1828
  }
1829
+ /**
1830
+ * Replace the in-memory `extras` list and re-apply the merged config
1831
+ * against the last-known workspace `mcp.json` state. Workspace
1832
+ * `mcp.json` still wins on name collision. No-op once `stop()` has
1833
+ * latched `mcpStopping`.
1834
+ */
1835
+ async setExtraMcpServers(extras) {
1836
+ if (!this._mcpRegistry || this.mcpStopping) return;
1837
+ this.mcpExtras = extras;
1838
+ await this.applyMerged(this.mcpLastJsonConfig);
1839
+ }
1840
+ async wirePersistence(cfg) {
1841
+ const servers = [];
1842
+ for (const s of cfg.servers) if (s.transport === `http` && s.auth?.mode === `authorizationCode`) {
1843
+ const persist = await keychainPersistence({ server: s.name });
1844
+ servers.push({
1845
+ ...s,
1846
+ auth: {
1847
+ ...s.auth,
1848
+ ...persist
1849
+ }
1850
+ });
1851
+ } else servers.push(s);
1852
+ return {
1853
+ ...cfg,
1854
+ servers
1855
+ };
1856
+ }
1857
+ mergeMcp(jsonCfg) {
1858
+ const jsonServers = jsonCfg?.servers ?? [];
1859
+ const jsonNames = new Set(jsonServers.map((s) => s.name));
1860
+ const filteredExtras = this.mcpExtras.filter((s) => !jsonNames.has(s.name));
1861
+ return {
1862
+ servers: [...filteredExtras, ...jsonServers],
1863
+ raw: jsonCfg?.raw
1864
+ };
1865
+ }
1866
+ async runApply(jsonCfg) {
1867
+ if (this.mcpStopping) return;
1868
+ const registry = this._mcpRegistry;
1869
+ if (!registry) return;
1870
+ try {
1871
+ const wired = await this.wirePersistence(this.mergeMcp(jsonCfg));
1872
+ if (this.mcpStopping) return;
1873
+ await registry.applyConfig(wired);
1874
+ } catch (e) {
1875
+ serverLog.error(`[mcp] applyConfig:`, e);
1876
+ try {
1877
+ this.options.onConfigError?.(e);
1878
+ } catch (cbErr) {
1879
+ serverLog.error(`[mcp] onConfigError callback failed:`, cbErr);
1880
+ }
1881
+ }
1882
+ }
1883
+ applyMerged(jsonCfg) {
1884
+ this.mcpLastJsonConfig = jsonCfg;
1885
+ const p = this.runApply(jsonCfg);
1886
+ this.mcpApplyInFlight.add(p);
1887
+ p.finally(() => this.mcpApplyInFlight.delete(p));
1888
+ return p;
1889
+ }
1767
1890
  async start() {
1768
1891
  if (this.bootstrap || this.pullWakeRunner) throw new Error(`Builtin agents runtime already started`);
1892
+ installDurableStreamsFetchCache(this.options.durableStreamsFetchCache);
1769
1893
  const pullWake = this.options.pullWake;
1770
1894
  if (!pullWake?.runnerId) throw new Error(`Builtin agents require a pull-wake runner id`);
1771
1895
  try {
@@ -1776,76 +1900,28 @@ var BuiltinAgentsServer = class {
1776
1900
  });
1777
1901
  this._mcpRegistry = mcpRegistry;
1778
1902
  const mcpConfigPath = this.options.loadProjectMcpConfig ? path.resolve(this.options.workingDirectory ?? process.cwd(), `mcp.json`) : null;
1779
- const extras = this.options.extraMcpServers ?? [];
1780
- const wirePersistence = async (cfg) => {
1781
- const servers = [];
1782
- for (const s of cfg.servers) if (s.transport === `http` && s.auth?.mode === `authorizationCode`) {
1783
- const persist = await keychainPersistence({ server: s.name });
1784
- servers.push({
1785
- ...s,
1786
- auth: {
1787
- ...s.auth,
1788
- ...persist
1789
- }
1790
- });
1791
- } else servers.push(s);
1792
- return {
1793
- ...cfg,
1794
- servers
1795
- };
1796
- };
1797
- const merge = (jsonCfg) => {
1798
- const jsonServers = jsonCfg?.servers ?? [];
1799
- const jsonNames = new Set(jsonServers.map((s) => s.name));
1800
- const filteredExtras = extras.filter((s) => !jsonNames.has(s.name));
1801
- return {
1802
- servers: [...filteredExtras, ...jsonServers],
1803
- raw: jsonCfg?.raw
1804
- };
1805
- };
1806
- const onConfigError = this.options.onConfigError;
1807
- const runApply = async (jsonCfg) => {
1808
- if (this.mcpStopping) return;
1809
- try {
1810
- const wired = await wirePersistence(merge(jsonCfg));
1811
- if (this.mcpStopping) return;
1812
- await mcpRegistry.applyConfig(wired);
1813
- } catch (e) {
1814
- serverLog.error(`[mcp] applyConfig:`, e);
1815
- try {
1816
- onConfigError?.(e);
1817
- } catch (cbErr) {
1818
- serverLog.error(`[mcp] onConfigError callback failed:`, cbErr);
1819
- }
1820
- }
1821
- };
1822
- const applyMerged = (jsonCfg) => {
1823
- const p = runApply(jsonCfg);
1824
- this.mcpApplyInFlight.add(p);
1825
- p.finally(() => this.mcpApplyInFlight.delete(p));
1826
- return p;
1827
- };
1903
+ this.mcpExtras = this.options.extraMcpServers ?? [];
1828
1904
  if (mcpConfigPath) {
1829
1905
  try {
1830
1906
  const cfg = await loadConfig(mcpConfigPath, process.env);
1831
- applyMerged(cfg);
1907
+ this.applyMerged(cfg);
1832
1908
  } catch (err) {
1833
1909
  if (err.code !== `ENOENT`) throw err;
1834
- if (extras.length === 0) serverLog.info(`[mcp] no ${mcpConfigPath} — starting with no servers`);
1835
- else serverLog.info(`[mcp] no ${mcpConfigPath} — starting with ${extras.length} server(s) from extras`);
1836
- applyMerged(null);
1910
+ if (this.mcpExtras.length === 0) serverLog.info(`[mcp] no ${mcpConfigPath} — starting with no servers`);
1911
+ else serverLog.info(`[mcp] no ${mcpConfigPath} — starting with ${this.mcpExtras.length} server(s) from extras`);
1912
+ this.applyMerged(null);
1837
1913
  }
1838
1914
  try {
1839
1915
  this.mcpWatcherCloser = await watchConfig(mcpConfigPath, {
1840
- onChange: (cfg) => void applyMerged(cfg),
1916
+ onChange: (cfg) => void this.applyMerged(cfg),
1841
1917
  onError: (e) => serverLog.error(`[mcp] config error:`, e)
1842
1918
  });
1843
1919
  } catch (e) {
1844
1920
  serverLog.error(`[mcp] config watcher failed to start:`, e);
1845
1921
  }
1846
1922
  } else {
1847
- if (extras.length > 0) serverLog.info(`[mcp] starting with ${extras.length} server(s) from extras`);
1848
- applyMerged(null);
1923
+ if (this.mcpExtras.length > 0) serverLog.info(`[mcp] starting with ${this.mcpExtras.length} server(s) from extras`);
1924
+ this.applyMerged(null);
1849
1925
  }
1850
1926
  this.mcpToolProviderName = `mcp`;
1851
1927
  registerToolProvider({
@@ -81,7 +81,7 @@ const proWorker = await ctx.spawn(
81
81
  systemPrompt: PRO_WORKER_PROMPT,
82
82
  sharedDb: { id: `debate-${ctx.entityUrl}`, schema: debateSchema },
83
83
  },
84
- { initialMessage: proInitialMessage, wake: `runFinished` }
84
+ { initialMessage: proInitialMessage, wake: { on: `runFinished`, includeResponse: true } }
85
85
  )
86
86
  ```
87
87
 
@@ -47,7 +47,7 @@ The dispatcher exposes a `dispatch` tool. When the LLM classifies an incoming me
47
47
 
48
48
  The tool then:
49
49
 
50
- 1. Spawns the requested entity type with `wake: 'runFinished'`.
50
+ 1. Spawns the requested entity type with `wake: { on: 'runFinished', includeResponse: true }`.
51
51
  2. Returns immediately with a status message. The dispatcher is re-invoked when the specialist finishes.
52
52
 
53
53
  ## Dispatch tool
@@ -59,7 +59,7 @@ await ctx.spawn(
59
59
  type === `worker` ? { systemPrompt, tools: [`read`] } : { systemPrompt },
60
60
  {
61
61
  initialMessage: task,
62
- wake: `runFinished`,
62
+ wake: { on: `runFinished`, includeResponse: true },
63
63
  }
64
64
  )
65
65
 
@@ -67,7 +67,7 @@ return {
67
67
  content: [
68
68
  {
69
69
  type: `text` as const,
70
- text: `Dispatched to "${type}" specialist (${id}). You will be woken when it finishes.`,
70
+ text: `Dispatched to "${type}" specialist (${id}). The dispatcher will continue when it finishes.`,
71
71
  },
72
72
  ],
73
73
  details: { id, type },
@@ -42,9 +42,9 @@ The manager defines a handler-scoped tool called `analyze_with_perspectives`. Wh
42
42
 
43
43
  1. Spawns 3 worker children -- optimist, pessimist, pragmatist -- each with a different system prompt.
44
44
  2. Sends the same question to all three as `initialMessage`.
45
- 3. Uses `wake: 'runFinished'` to wait for each child to complete.
46
- 4. Collects results with `Promise.all` and `handle.text()`.
47
- 5. Returns the combined perspectives to the LLM for synthesis.
45
+ 3. Uses `wake: { on: 'runFinished', includeResponse: true }` so the manager is re-invoked as each child completes.
46
+ 4. Collects results from `runFinished` wake payloads or shared state after workers finish.
47
+ 5. Runs a synthesis step after all child-completion wakes have been recorded.
48
48
 
49
49
  On subsequent calls, the tool reuses existing children via `ctx.observe()` and `child.send()` instead of spawning new ones.
50
50
 
@@ -63,7 +63,7 @@ for (const perspective of PERSPECTIVES) {
63
63
  `worker`,
64
64
  childId,
65
65
  { systemPrompt: perspective.systemPrompt, tools: [`read`] },
66
- { initialMessage: question, wake: `runFinished` }
66
+ { initialMessage: question, wake: { on: `runFinished`, includeResponse: true } }
67
67
  )
68
68
  children.insert({
69
69
  key: perspective.id,
@@ -87,28 +87,16 @@ for (const perspective of PERSPECTIVES) {
87
87
 
88
88
  ## Collecting results
89
89
 
90
- The `readLatestCompletedText` helper awaits the child's current run, reads all text outputs, and returns the last one:
90
+ Do not wait for worker output inside the same wake. Spawn workers with `wake: { on: "runFinished", includeResponse: true }`, record each worker URL in manager state, and return. On each later child-completion wake, store `wake.payload.finished_child.response` (or read structured output from shared state). Once all workers have reported, run the reduce/synthesis step.
91
91
 
92
92
  ```ts
93
- async function readLatestCompletedText(
94
- handle: EntityHandle,
95
- fallback: string
96
- ): Promise<string> {
97
- await handle.run
98
- const runs = await handle.text()
99
- const latest = runs[runs.length - 1]?.trim()
100
- return latest || fallback
93
+ const finished = wake.payload?.finished_child
94
+ if (finished) {
95
+ ctx.state.workers.update(finished.url, (draft) => {
96
+ draft.status = finished.run_status
97
+ draft.output = finished.response ?? ""
98
+ })
101
99
  }
102
-
103
- const results = await Promise.all(
104
- handles.map(async ({ id, handle }) => ({
105
- id,
106
- text: await readLatestCompletedText(
107
- handle,
108
- `(no completed output from ${id})`
109
- ),
110
- }))
111
- )
112
100
  ```
113
101
 
114
102
  ## State
@@ -55,7 +55,7 @@ for (let i = 0; i < chunks.length; i++) {
55
55
  { systemPrompt: task, tools: [`read`] },
56
56
  {
57
57
  initialMessage: chunks[i],
58
- wake: `runFinished`,
58
+ wake: { on: `runFinished`, includeResponse: true },
59
59
  }
60
60
  )
61
61
  ctx.db.actions.children_insert({
@@ -39,7 +39,7 @@ export function registerPipeline(registry: EntityRegistry) {
39
39
  The pipeline agent exposes a `run_stage` tool. The LLM drives the pipeline one stage at a time:
40
40
 
41
41
  1. The LLM calls `run_stage` with an instruction and input for the current stage.
42
- 2. The tool spawns a worker with the instruction as its system prompt and the input as `initialMessage`, using `wake: 'runFinished'`.
42
+ 2. The tool spawns a worker with the instruction as its system prompt and the input as `initialMessage`, using `wake: { on: 'runFinished', includeResponse: true }`.
43
43
  3. The tool returns immediately. The pipeline entity is re-invoked when the worker finishes.
44
44
  4. On each re-invocation, the wake event contains `finished_child.response` with the stage's output. The LLM then calls `run_stage` again with the next stage's instruction and the previous output as input.
45
45
  5. This repeats until all stages are complete.
@@ -74,7 +74,7 @@ function createRunStageTool(ctx: HandlerContext): AgentTool {
74
74
  `worker`,
75
75
  id,
76
76
  { systemPrompt: instruction, tools: [`read`] },
77
- { initialMessage: input, wake: `runFinished` }
77
+ { initialMessage: input, wake: { on: `runFinished`, includeResponse: true } }
78
78
  )
79
79
  ctx.db.actions.children_insert({
80
80
  row: { key: id, url: child.entityUrl, stage: stageCount },
@@ -84,7 +84,7 @@ function createRunStageTool(ctx: HandlerContext): AgentTool {
84
84
  content: [
85
85
  {
86
86
  type: `text` as const,
87
- text: `Stage ${stageCount} spawned. You will be woken when it finishes.`,
87
+ text: `Stage ${stageCount} spawned. The pipeline will continue when it finishes.`,
88
88
  },
89
89
  ],
90
90
  details: { stage: stageCount },