@agentswf/engine 0.1.0-beta.2 → 0.1.0-beta.4

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 (82) hide show
  1. package/package.json +5 -6
  2. package/src/agent-cli/cli.ts +571 -0
  3. package/src/agent-cli/client.ts +115 -0
  4. package/src/agent-launcher.ts +15 -25
  5. package/src/allowance-command.ts +31 -21
  6. package/src/attempt-ending.ts +23 -5
  7. package/src/attempt-output.ts +48 -18
  8. package/src/attempt-view.ts +25 -15
  9. package/src/commands.ts +176 -0
  10. package/src/control-plane.ts +73 -22
  11. package/src/coordinator-port.ts +29 -0
  12. package/src/coordinator-prompt.ts +237 -0
  13. package/src/coordinator-socket.ts +236 -0
  14. package/src/deadlines.ts +47 -10
  15. package/src/decisions/directory.ts +8 -6
  16. package/src/duration.ts +29 -0
  17. package/src/emit-command.ts +119 -0
  18. package/src/emit.ts +246 -0
  19. package/src/files.ts +11 -1
  20. package/src/help/allowance.txt +13 -0
  21. package/src/help/emit.txt +13 -0
  22. package/src/help/inbox.txt +12 -0
  23. package/src/help/init.txt +12 -0
  24. package/src/help/logs.txt +13 -0
  25. package/src/help/reply.txt +18 -0
  26. package/src/help/run.txt +44 -0
  27. package/src/help/runs.txt +11 -0
  28. package/src/help/test.txt +9 -0
  29. package/src/help/tick.txt +12 -0
  30. package/src/here.ts +9 -3
  31. package/src/hold-run-lock.fixture.ts +8 -0
  32. package/src/holder-command.ts +279 -0
  33. package/src/inbox-command.ts +715 -0
  34. package/src/init-command.ts +29 -11
  35. package/src/init-template/AGENTS.md.template +4 -4
  36. package/src/init-template/hello/workflow.test.ts.template +2 -2
  37. package/src/init-template/hello/workflow.ts.template +16 -20
  38. package/src/keep-alive.ts +40 -0
  39. package/src/logs-command.ts +106 -35
  40. package/src/machine.ts +12 -1
  41. package/src/mailbox.ts +990 -0
  42. package/src/message-operation.ts +26 -3
  43. package/src/messaging/limits.ts +1 -1
  44. package/src/messaging/messaging.ts +478 -85
  45. package/src/messaging/routes.ts +11 -4
  46. package/src/messaging/text.ts +25 -22
  47. package/src/operation-liveness.ts +55 -9
  48. package/src/operator-cli.ts +290 -119
  49. package/src/operator-runtime.ts +8 -8
  50. package/src/progress-view.ts +49 -7
  51. package/src/projects.ts +57 -0
  52. package/src/result-slots.ts +43 -19
  53. package/src/result-validation.ts +18 -27
  54. package/src/run-command.ts +29 -66
  55. package/src/run-commands.ts +13 -2
  56. package/src/run-lock.ts +123 -0
  57. package/src/run-prepare.ts +94 -22
  58. package/src/run-state.ts +125 -6
  59. package/src/run-usage.ts +2 -2
  60. package/src/run-view.ts +0 -9
  61. package/src/runs-command.ts +43 -25
  62. package/src/runs.ts +142 -23
  63. package/src/sandboxes.ts +11 -4
  64. package/src/simple-input.ts +268 -0
  65. package/src/socket-exchange.ts +26 -0
  66. package/src/sources.ts +70 -0
  67. package/src/stage-ledger.ts +89 -14
  68. package/src/stage-plan.ts +23 -0
  69. package/src/stop-crashed.ts +39 -0
  70. package/src/test-command.ts +34 -11
  71. package/src/testing.ts +72 -4
  72. package/src/tick-command.ts +76 -0
  73. package/src/tools.ts +645 -0
  74. package/src/wake.ts +264 -0
  75. package/src/workflow-loader.ts +32 -22
  76. package/src/workflow-runner.ts +1277 -97
  77. package/src/workflow-testing/coordinator.typecheck.ts +54 -0
  78. package/src/workflow-testing/coordinators.ts +137 -0
  79. package/src/workflow-testing/host.ts +67 -7
  80. package/src/workflow-testing/index.ts +488 -34
  81. package/src/workflow-testing/script.ts +74 -4
  82. package/src/workflow-testing/workflow-testing.typecheck.ts +19 -2
@@ -1,21 +1,39 @@
1
1
  import { existsSync, lstatSync, mkdirSync, readFileSync, statSync, writeFileSync } from "node:fs";
2
2
  import { basename, dirname, join, relative, resolve } from "node:path";
3
3
  import manifest from "../package.json" with { type: "json" };
4
+ import { messageOf } from "./errors";
5
+ import help from "./help/init.txt" with { type: "text" };
6
+ import type { Out } from "./run-commands";
4
7
 
5
- export const initUsage = [
6
- "usage: awf init [dir] [--no-install]",
7
- "",
8
- "Sets up the current directory, or dir, for writing workflows: hello/workflow.ts, a workflow",
9
- "with one agent, and hello/workflow.test.ts, its test; package.json, tsconfig.json, .gitignore",
10
- "and AGENTS.md. Then runs bun install, unless --no-install, for editor types: awf runs a",
11
- "workflow against its own copies either way.",
12
- "",
13
- "Never overwrites. A file that exists is skipped, except that package.json gains the",
14
- "devDependencies and scripts it lacks and .gitignore the lines it lacks.",
15
- ].join("\n");
8
+ export const initUsage = help.trimEnd();
16
9
 
17
10
  export type InitCommand = { dir: string; install: boolean };
18
11
 
12
+ /** `awf init`: a folder set up for writing workflows. */
13
+ export async function initCommand(
14
+ argv: readonly string[],
15
+ cwd: string,
16
+ io: Out & { install?: (dir: string) => Promise<number> },
17
+ ): Promise<number> {
18
+ let command: InitCommand | "help";
19
+ try {
20
+ command = parseInitCommand(argv, cwd);
21
+ } catch (error) {
22
+ io.stderr(`awf: ${messageOf(error)}\n\n${initUsage}`);
23
+ return 2;
24
+ }
25
+ if (command === "help") {
26
+ io.stdout(initUsage);
27
+ return 0;
28
+ }
29
+ try {
30
+ return await runInit(command, cwd, io, io.install);
31
+ } catch (error) {
32
+ io.stderr(`awf: ${messageOf(error)}`);
33
+ return 1;
34
+ }
35
+ }
36
+
19
37
  export function parseInitCommand(argv: readonly string[], cwd: string): InitCommand | "help" {
20
38
  let dir: string | undefined;
21
39
  let install = true;
@@ -1,13 +1,13 @@
1
1
  # Workflows
2
2
 
3
- This folder holds [agents.wf](https://github.com/romanlv/agentswf) workflows: TypeScript files
3
+ This folder holds [agents.wf](https://agents.wf) workflows: TypeScript files
4
4
  that run coding agents, started with `awf`.
5
5
 
6
6
  - A workflow is a folder with a `workflow.ts`, which default-exports
7
- `defineExecutableWorkflow(…)` from `agentswf/workflow`, and a `workflow.test.ts` beside it.
7
+ `defineWorkflow(…)` from `agentswf/workflow`, and a `workflow.test.ts` beside it.
8
8
  `hello/` is the smallest one; copy it to start another.
9
- - The API, with every option and outcome:
10
- https://github.com/romanlv/agentswf/blob/main/docs/workflow-api.md
9
+ - The API, with every option and outcome, as Markdown:
10
+ https://agents.wf/reference/workflow-api.md. Every other page is in https://agents.wf/llms.txt.
11
11
  - `awf test` runs the tests with each agent replaced by scripted answers: no agent starts, and it
12
12
  costs nothing. After a change, run it and `bun run check`, which typechecks.
13
13
  - `awf run hello/workflow.ts` starts real agents, on the operator's accounts. Don't run a workflow
@@ -3,7 +3,7 @@ import { answer, reply, testWorkflow } from "agentswf/testing";
3
3
  import hello, { ANSWER } from "./workflow";
4
4
 
5
5
  test("returns the agent's answer", async () => {
6
- const run = await testWorkflow(hello, {}, {
6
+ const run = await testWorkflow(hello, null, {
7
7
  agents: { hello: answer(ANSWER, { answer: 391 }) },
8
8
  });
9
9
  expect(run.value).toEqual({ answer: 391 });
@@ -11,7 +11,7 @@ test("returns the agent's answer", async () => {
11
11
  });
12
12
 
13
13
  test("an agent that never answers is nudged once, then the result says why", async () => {
14
- const run = await testWorkflow(hello, {}, { agents: { hello: reply.silent() } });
14
+ const run = await testWorkflow(hello, null, { agents: { hello: reply.silent() } });
15
15
  expect(run.value).toHaveProperty("unanswered");
16
16
  expect(run.turnsOf("hello").map((turn) => turn.nudge)).toEqual([false, true]);
17
17
  });
@@ -1,26 +1,22 @@
1
- import { defineExecutableWorkflow, isAnswered } from "agentswf/workflow";
1
+ import { defineWorkflow } from "agentswf/workflow";
2
2
  import Type from "typebox";
3
3
 
4
4
  export const ANSWER = Type.Object({ answer: Type.Integer() }, { additionalProperties: false });
5
5
 
6
- export default defineExecutableWorkflow({
7
- definition: {
8
- meta: { name: "hello", description: "Ask one agent a question with a known answer." },
9
- async run(workflow) {
10
- // A headless agent needs neither Herdr nor a sandbox. Swap in any harness and model your
11
- // account has: "claude" in a pane, the default placement, runs on a subscription.
12
- const agent = await workflow.agents.open({
13
- key: "hello",
14
- runtime: { harness: "codex", model: "gpt-6-luna", placement: "headless" },
15
- });
16
- const { outcome } = await agent.run<Type.Static<typeof ANSWER>>({
17
- prompt: "What is 17 multiplied by 23? Work it out yourself; do not run any code.",
18
- schema: ANSWER,
19
- timeoutMs: 3 * 60_000,
20
- });
21
- return isAnswered(outcome) ? outcome.value : { unanswered: outcome.reason };
22
- },
6
+ export default defineWorkflow({
7
+ meta: { name: "hello", description: "Ask one agent a question with a known answer." },
8
+ async run(wf) {
9
+ // A headless agent needs neither Herdr nor a sandbox. Swap in any harness and model your
10
+ // account has: "claude" in a pane, the default placement, runs on a subscription.
11
+ const agent = await wf.agents.open({
12
+ key: "hello",
13
+ runtime: { harness: "codex", model: "gpt-6-luna", placement: "headless" },
14
+ });
15
+ const { outcome } = await agent.run<Type.Static<typeof ANSWER>>({
16
+ prompt: "What is 17 multiplied by 23? Work it out yourself; do not run any code.",
17
+ schema: ANSWER,
18
+ timeoutMs: 3 * 60_000,
19
+ });
20
+ return outcome.answered ? outcome.value : { unanswered: outcome.reason };
23
21
  },
24
- // Turns the command line into args: `awf run hello/workflow.ts -- ...`.
25
- prepare: () => ({}),
26
22
  });
@@ -0,0 +1,40 @@
1
+ import { AsyncLocalStorage } from "node:async_hooks";
2
+ import type { EventAgentOpenSpec } from "@agentswf/contract/workflow";
3
+
4
+ export type KeepAlive = NonNullable<EventAgentOpenSpec["keepAlive"]>;
5
+
6
+ /** Claude's prompt cache lives an hour for the main conversation on a subscription. */
7
+ export const DEFAULT_WINDOW_MS = 60 * 60_000;
8
+
9
+ /**
10
+ * Set while an `onTimeout` handler runs: a turn begun in it opens no window, so a wrap-up can't keep
11
+ * its own agent live. A message turn opens one whatever the context: it is the peer's.
12
+ */
13
+ const inHandler = new AsyncLocalStorage<boolean>();
14
+
15
+ export function inTimeoutHandler(): boolean {
16
+ return inHandler.getStore() === true;
17
+ }
18
+
19
+ export function asTimeoutHandler<T>(fn: () => T): T {
20
+ return inHandler.run(true, fn);
21
+ }
22
+
23
+ /** `keepAlive` as an open gave it, checked: what a typed caller can't get wrong, a plain one can. */
24
+ export function checkedKeepAlive(key: string, given: unknown): KeepAlive {
25
+ if (typeof given !== "object" || given === null)
26
+ throw new Error(`agent ${key}: keepAlive is { timeoutMs?, onTimeout? }`);
27
+ const { timeoutMs, onTimeout } = given as Record<string, unknown>;
28
+ if (
29
+ timeoutMs !== undefined &&
30
+ (typeof timeoutMs !== "number" || !Number.isFinite(timeoutMs) || timeoutMs <= 0)
31
+ )
32
+ throw new Error(`agent ${key}: keepAlive's timeoutMs is a positive number of milliseconds`);
33
+ if (onTimeout !== undefined && typeof onTimeout !== "function")
34
+ throw new Error(`agent ${key}: keepAlive's onTimeout is a function`);
35
+ // A copy: a later change to the caller's object would skip this check.
36
+ return {
37
+ ...(timeoutMs === undefined ? {} : { timeoutMs }),
38
+ ...(onTimeout === undefined ? {} : { onTimeout: onTimeout as KeepAlive["onTimeout"] }),
39
+ } as KeepAlive;
40
+ }
@@ -1,5 +1,9 @@
1
+ import { homedir } from "node:os";
1
2
  import type { LogEventJson, LogsJson, RouteEnds, RunLogLine } from "@agentswf/contract/records";
3
+ import type { JsonObject } from "@agentswf/contract/workflow";
4
+ import { duration } from "./duration";
2
5
  import { messageOf } from "./errors";
6
+ import help from "./help/logs.txt" with { type: "text" };
3
7
  import {
4
8
  clockTime,
5
9
  command,
@@ -28,24 +32,10 @@ import {
28
32
  readRun,
29
33
  timeOf,
30
34
  } from "./run-read";
31
- import { findRun, type RunStatus } from "./run-state";
32
- import { describeActivity, duration } from "./run-view";
35
+ import { findRun, findRunByRef, type RunStatus } from "./run-state";
36
+ import { describeActivity } from "./run-view";
33
37
 
34
- export const logsUsage = [
35
- "usage: awf logs [<run>] [@<handle>] [--agent <key>] [--stage <name>] [--after <cursor>]",
36
- " [--limit <n>] [--wait <seconds> | --follow] [--full] [--cwd <directory>]",
37
- " [--run-root <directory>] [--json]",
38
- "",
39
- "What a run did and is doing: its events and what its agents did, read from their own session",
40
- "files, merged by time. With no run, the newest here. Without --after, the last 40 events; with",
41
- "it, what came after, oldest first. Every answer ends with the command that gets only what is",
42
- "new. --wait: with nothing to show yet, wait for the first event, at most that many seconds, up",
43
- "to 300; --follow prints each event as it comes until the run ends. @<handle> names one event,",
44
- "and --full prints its text, with common credential shapes masked; --json carries it always.",
45
- "",
46
- "Exits 0 on any read that worked, 1 when there is no such run or the cursor or handle does not",
47
- "fit it, 2 on a usage error, 3 when the run's files could not be read.",
48
- ].join("\n");
38
+ export const logsUsage = help.trimEnd();
49
39
 
50
40
  const DEFAULT_LIMIT = 40;
51
41
  /**
@@ -82,6 +72,7 @@ export async function logsCommand(
82
72
  shellCwd: string,
83
73
  out: Out,
84
74
  clock: LogsClock = REAL_CLOCK,
75
+ home: string = homedir(),
85
76
  ): Promise<number> {
86
77
  if (argv.includes("-h") || argv.includes("--help")) {
87
78
  out.stdout(logsUsage);
@@ -101,7 +92,10 @@ export async function logsCommand(
101
92
  return 2;
102
93
  }
103
94
  try {
104
- const { status, alsoRunning } = await findRun(place.runRoot, options.ref);
95
+ const { status, alsoRunning } =
96
+ options.ref === undefined
97
+ ? await findRun(place.runRoot, undefined)
98
+ : { status: await findRunByRef(options.ref, place, home), alsoRunning: [] };
105
99
  const again = shellLine(command(place, "awf", "logs", status.ref));
106
100
  let after: Cursor | undefined;
107
101
  if (options.after !== undefined) {
@@ -200,20 +194,31 @@ class Reader {
200
194
  });
201
195
  }
202
196
 
203
- async refresh(runRoot: string): Promise<RunStatus> {
204
- this.status = (await findRun(runRoot, this.status.ref)).status;
197
+ /** The run read again where it was found, which may be another project's. */
198
+ async refresh(): Promise<RunStatus> {
199
+ this.status = (await findRun(this.status.run.root, this.status.ref)).status;
205
200
  return this.status;
206
201
  }
207
202
  }
208
203
 
209
204
  type Read = Awaited<ReturnType<Reader["next"]>>;
210
205
 
211
- /** Each operation's stage and agent, from the lines that name them. */
212
- type Places = { stage: Map<string, string>; agent: Map<string, string> };
206
+ /** Where each operation ran, and each tool call's input, by its id, so its end shows it. */
207
+ type Places = {
208
+ stage: Map<string, string>;
209
+ agent: Map<string, string>;
210
+ calls: Map<string, { input: JsonObject; ended: boolean }>;
211
+ };
213
212
 
214
213
  function operationPlaces(lines: readonly { line: RunLogLine }[]): Places {
215
- const places: Places = { stage: new Map(), agent: new Map() };
214
+ const places: Places = { stage: new Map(), agent: new Map(), calls: new Map() };
216
215
  for (const { line } of lines) {
216
+ if (line.kind === "tool-called")
217
+ places.calls.set(line.call, { input: line.input, ended: false });
218
+ if (line.kind === "tool-ended") {
219
+ const call = places.calls.get(line.call);
220
+ if (call) call.ended = true;
221
+ }
217
222
  if (!("operationId" in line) || !line.operationId) continue;
218
223
  if ("stage" in line && line.stage) places.stage.set(line.operationId, line.stage);
219
224
  if ("agent" in line && line.agent) places.agent.set(line.operationId, line.agent);
@@ -257,10 +262,17 @@ function sidesOf(event: ReadEvent): readonly string[] {
257
262
  }
258
263
  }
259
264
 
260
- function kept(read: Read, options: LogsOptions) {
265
+ function kept(read: Read, options: LogsOptions, json = false) {
261
266
  const stages = operationPlaces(read.log.lines);
262
267
  const events = read.events.filter(
263
268
  (event) =>
269
+ // A call that has ended shows on its end's line, with its input; `--json` keeps both.
270
+ !(
271
+ !json &&
272
+ event.source !== "activity" &&
273
+ event.line.kind === "tool-called" &&
274
+ stages.calls.get(event.line.call)?.ended
275
+ ) &&
264
276
  (options.agent === undefined ||
265
277
  agentOf(event, stages) === options.agent ||
266
278
  sidesOf(event).includes(options.agent)) &&
@@ -290,7 +302,7 @@ async function page(
290
302
  clock: LogsClock,
291
303
  ): Promise<number> {
292
304
  let read = await reader.next();
293
- let { events, stages } = kept(read, options);
305
+ let { events, stages } = kept(read, options, json);
294
306
  const deadline = clock.now() + options.waitSeconds * 1000;
295
307
  let waited = false;
296
308
  // Without a cursor, a wait reads on from its first read: nothing before it is kept.
@@ -305,14 +317,14 @@ async function page(
305
317
  waited = true;
306
318
  if (!reader.from) from = read.cursor;
307
319
  await clock.sleep(POLL_MS);
308
- await reader.refresh(place.runRoot);
320
+ await reader.refresh();
309
321
  read = await reader.next(from);
310
- ({ events, stages } = kept(read, options));
322
+ ({ events, stages } = kept(read, options, json));
311
323
  if (events.length > 0) {
312
324
  // A burst comes back as one page.
313
325
  await clock.sleep(SETTLE_MS);
314
326
  read = await reader.next(from);
315
- ({ events, stages } = kept(read, options));
327
+ ({ events, stages } = kept(read, options, json));
316
328
  }
317
329
  }
318
330
  const status = reader.status;
@@ -417,7 +429,7 @@ async function follow(
417
429
  if (reader.status.state !== "running") break;
418
430
  await clock.sleep(POLL_MS);
419
431
  }
420
- await reader.refresh(place.runRoot);
432
+ await reader.refresh();
421
433
  read = await reader.next(cursor);
422
434
  }
423
435
  out.stdout(trailer(reader.status, read, place, undefined, undefined, undefined));
@@ -532,11 +544,13 @@ function describeEvent(event: ReadEvent, stages: Places): string {
532
544
  const agent = agentOf(event, stages);
533
545
  const where = [stageOf(event, stages), agent && said(agent, 60)].filter(Boolean).join(" ");
534
546
  const what =
535
- event.source === "activity" ? describeActivity(event.activity) : describeLine(event.line);
547
+ event.source === "activity"
548
+ ? describeActivity(event.activity)
549
+ : describeLine(event.line, stages);
536
550
  return `@${event.handle} ${clockTime(event.at)} ${where ? `${where} ` : ""}${what}`;
537
551
  }
538
552
 
539
- function describeLine(line: RunLogLine): string {
553
+ function describeLine(line: RunLogLine, places: Places): string {
540
554
  const operation = (of: "turn" | "compact") => (of === "compact" ? "compaction" : "turn");
541
555
  switch (line.kind) {
542
556
  case "attempt-started":
@@ -576,15 +590,21 @@ function describeLine(line: RunLogLine): string {
576
590
  case "decision-ended":
577
591
  return `decision ${line.decision} ${line.outcome}${line.error ? ` ${said(line.error)}` : ""}`;
578
592
  case "route-opened":
579
- return `route ${routeEnds(line)} opened "${said(line.purpose)}" ${line.maxMessages} messages`;
593
+ return `route ${routeEnds(line)} opened "${said(line.purpose)}" ${line.maxMessages} messages${
594
+ line.timeoutMs === undefined ? "" : `, a question closes after ${duration(line.timeoutMs)}`
595
+ }`;
580
596
  case "route-closed":
581
597
  return `route ${routeEnds(line)} closed ${line.reason}`;
582
598
  case "message-sent":
583
599
  return `message ${line.message} ${said(line.from, 60)} → ${said(line.to, 60)} ${line.bytes} B${
584
- line.replyTo === undefined ? "" : `, reply to ${line.replyTo}`
585
- }${
600
+ line.via === undefined ? "" : ` via ${line.via}`
601
+ }${line.replyTo === undefined ? "" : `, reply to ${line.replyTo}`}${
586
602
  line.awaitsReply
587
- ? `, reply owed ${said(line.from, 60)} awaits it until ${clockTime(Date.parse(line.awaitsReply.until))}`
603
+ ? `, reply owed ${said(line.from, 60)} awaits it${
604
+ line.awaitsReply.until
605
+ ? ` until ${clockTime(Date.parse(line.awaitsReply.until))}`
606
+ : ""
607
+ }`
588
608
  : ""
589
609
  }`;
590
610
  case "message-refused":
@@ -595,9 +615,60 @@ function describeLine(line: RunLogLine): string {
595
615
  return `message ${line.message} undelivered ${line.reason}`;
596
616
  case "request-ended":
597
617
  return `request ${line.message} ${line.end}${line.reason ? ` ${said(line.reason)}` : ""}`;
618
+ case "participant-connected":
619
+ return `${said(line.participant, 60)} connected${line.to === "code" ? " to the code" : ""}`;
620
+ case "participant-post":
621
+ return `run → ${said(line.participant, 60)} ${said(line.text)}`;
622
+ // A call is one line, its end's: what it was given, how it ended, and what it said back.
623
+ // Shown alone only while its end isn't in the log: still running, or cut short by a crash.
624
+ case "tool-called":
625
+ return toolLine(
626
+ line.tool,
627
+ line.input,
628
+ places.calls.get(line.call)?.ended ? "called" : "no end yet",
629
+ );
630
+ case "tool-ended": {
631
+ const answered =
632
+ line.outcome !== "ok"
633
+ ? line.error
634
+ : typeof line.result === "string"
635
+ ? `→ ${line.result}`
636
+ : line.result === undefined
637
+ ? undefined
638
+ : `→ ${JSON.stringify(line.result)}`;
639
+ return toolLine(
640
+ line.tool,
641
+ places.calls.get(line.call)?.input,
642
+ // Most calls take under a second; a duration says something only past one.
643
+ line.durationMs < 1000 ? line.outcome : `${line.outcome} ${duration(line.durationMs)}`,
644
+ answered,
645
+ );
646
+ }
647
+ case "tool-refused":
648
+ return [
649
+ `tool call ${said(line.tool, 60)}`,
650
+ "refused",
651
+ line.code,
652
+ line.error && said(line.error),
653
+ ]
654
+ .filter(Boolean)
655
+ .join(" ");
598
656
  }
599
657
  }
600
658
 
659
+ /** `tool call close_item {"id":"a1"} ok → Closed a1.`, apart from the harness's own tools. */
660
+ function toolLine(
661
+ tool: string,
662
+ input: JsonObject | undefined,
663
+ how: string,
664
+ answered?: string,
665
+ ): string {
666
+ const given = input && Object.keys(input).length > 0 ? ` ${said(JSON.stringify(input), 80)}` : "";
667
+ return [`tool call ${said(tool, 60)}${given}`, how, answered && said(answered)]
668
+ .filter(Boolean)
669
+ .join(" ");
670
+ }
671
+
601
672
  function routeEnds(line: RouteEnds): string {
602
673
  return line.between
603
674
  ? `${said(line.between[0], 60)} ↔ ${said(line.between[1], 60)}`
package/src/machine.ts CHANGED
@@ -4,13 +4,15 @@ import type { RunRecord } from "@agentswf/contract/records";
4
4
  /**
5
5
  * What awf keeps per machine, under `~/.awf`, whatever a run's root: the marks of sessions runs
6
6
  * drive, each sandbox's homes, kept outside the project, whose run root a provider denies, and the
7
- * Herdr sessions it runs agents in: their config, and each run's mark on its workspace.
7
+ * Herdr sessions it runs agents in: their config, and each run's mark on its workspace; and the
8
+ * sockets holders reach live runs' coordinators through.
8
9
  */
9
10
  export function machinePaths(home: string): {
10
11
  root: string;
11
12
  callers: string;
12
13
  sandboxes: string;
13
14
  herdr: string;
15
+ coordinator: string;
14
16
  } {
15
17
  const root = join(home, ".awf");
16
18
  return {
@@ -18,6 +20,7 @@ export function machinePaths(home: string): {
18
20
  callers: join(root, "callers"),
19
21
  sandboxes: join(root, "sandboxes"),
20
22
  herdr: join(root, "herdr"),
23
+ coordinator: join(root, "coordinator"),
21
24
  };
22
25
  }
23
26
 
@@ -25,3 +28,11 @@ export function machinePaths(home: string): {
25
28
  export function sandboxesDirOf(home: string, run: Pick<RunRecord, "workflow" | "id">): string {
26
29
  return join(machinePaths(home).sandboxes, run.workflow, run.id);
27
30
  }
31
+
32
+ /** Where live runs' coordinator sockets are: `AWF_COORDINATOR_DIR`, or `~/.awf/coordinator`. */
33
+ export function coordinatorDir(
34
+ home: string,
35
+ environment: Readonly<Record<string, string | undefined>>,
36
+ ): string {
37
+ return environment.AWF_COORDINATOR_DIR || machinePaths(home).coordinator;
38
+ }