@mikode13/harness 11.0.1 → 11.2.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 (120) hide show
  1. package/README.md +186 -16
  2. package/dist/agent/domain/agent.d.ts +41 -3
  3. package/dist/agent/domain/agent.js.map +1 -1
  4. package/dist/agent/domain/approval.d.ts +20 -0
  5. package/dist/agent/domain/approval.js.map +1 -1
  6. package/dist/agent/domain/inbox.d.ts +42 -0
  7. package/dist/agent/domain/inbox.js +81 -0
  8. package/dist/agent/domain/inbox.js.map +1 -0
  9. package/dist/agent/domain/oneRunAtATime.d.ts +17 -0
  10. package/dist/agent/domain/oneRunAtATime.js +32 -0
  11. package/dist/agent/domain/oneRunAtATime.js.map +1 -0
  12. package/dist/agent/domain/runContext.d.ts +9 -0
  13. package/dist/agent/domain/runContext.js +5 -0
  14. package/dist/agent/domain/runContext.js.map +1 -1
  15. package/dist/agent/domain/session.d.ts +15 -0
  16. package/dist/agent/domain/session.js +80 -0
  17. package/dist/agent/domain/session.js.map +1 -0
  18. package/dist/engines/claude/infrastructure/model/claudeAgent.d.ts +6 -2
  19. package/dist/engines/claude/infrastructure/model/claudeAgent.js +20 -5
  20. package/dist/engines/claude/infrastructure/model/claudeAgent.js.map +1 -1
  21. package/dist/engines/codex/infrastructure/model/codexAgent.d.ts +5 -1
  22. package/dist/engines/codex/infrastructure/model/codexAgent.js +13 -2
  23. package/dist/engines/codex/infrastructure/model/codexAgent.js.map +1 -1
  24. package/dist/engines/domain/model/backgroundAgents.d.ts +5 -2
  25. package/dist/engines/domain/model/backgroundAgents.js +11 -3
  26. package/dist/engines/domain/model/backgroundAgents.js.map +1 -1
  27. package/dist/engines/domain/model/compactor.d.ts +40 -0
  28. package/dist/engines/domain/model/compactor.js +5 -0
  29. package/dist/engines/domain/model/compactor.js.map +1 -0
  30. package/dist/engines/domain/model/llmAgent.d.ts +53 -2
  31. package/dist/engines/domain/model/llmAgent.js +419 -24
  32. package/dist/engines/domain/model/llmAgent.js.map +1 -1
  33. package/dist/engines/domain/model/recentCompaction.d.ts +39 -0
  34. package/dist/engines/domain/model/recentCompaction.js +119 -0
  35. package/dist/engines/domain/model/recentCompaction.js.map +1 -0
  36. package/dist/engines/domain/model/stepContext.js +3 -0
  37. package/dist/engines/domain/model/stepContext.js.map +1 -1
  38. package/dist/engines/domain/model/triagedCompaction.d.ts +27 -0
  39. package/dist/engines/domain/model/triagedCompaction.js +60 -0
  40. package/dist/engines/domain/model/triagedCompaction.js.map +1 -0
  41. package/dist/factory/infrastructure/agentLLMFactory.d.ts +56 -15
  42. package/dist/factory/infrastructure/agentLLMFactory.js +144 -25
  43. package/dist/factory/infrastructure/agentLLMFactory.js.map +1 -1
  44. package/dist/index.d.ts +6 -2
  45. package/dist/index.js +3 -0
  46. package/dist/index.js.map +1 -1
  47. package/dist/llm/domain/contextTriage.d.ts +26 -0
  48. package/dist/llm/domain/contextTriage.js +3 -0
  49. package/dist/llm/domain/contextTriage.js.map +1 -0
  50. package/dist/llm/domain/llm.d.ts +18 -1
  51. package/dist/llm/domain/llm.js.map +1 -1
  52. package/dist/llm/domain/message.d.ts +18 -1
  53. package/dist/llm/domain/message.js +20 -0
  54. package/dist/llm/domain/message.js.map +1 -1
  55. package/dist/llm/infrastructure/claudeLLMClient.d.ts +4 -5
  56. package/dist/llm/infrastructure/claudeLLMClient.js +39 -13
  57. package/dist/llm/infrastructure/claudeLLMClient.js.map +1 -1
  58. package/dist/llm/infrastructure/jevContextTriage.d.ts +18 -0
  59. package/dist/llm/infrastructure/jevContextTriage.js +92 -0
  60. package/dist/llm/infrastructure/jevContextTriage.js.map +1 -0
  61. package/dist/llm/infrastructure/openAILLMClient.d.ts +4 -5
  62. package/dist/llm/infrastructure/openAILLMClient.js +20 -9
  63. package/dist/llm/infrastructure/openAILLMClient.js.map +1 -1
  64. package/dist/orchestration/domain/model/orchestratorAgent.d.ts +9 -1
  65. package/dist/orchestration/domain/model/orchestratorAgent.js +20 -3
  66. package/dist/orchestration/domain/model/orchestratorAgent.js.map +1 -1
  67. package/dist/recovery/domain/history.d.ts +10 -3
  68. package/dist/recovery/domain/history.js +1 -0
  69. package/dist/recovery/domain/history.js.map +1 -1
  70. package/dist/recovery/domain/recoveryStore.d.ts +14 -2
  71. package/dist/recovery/domain/recoveryStore.js.map +1 -1
  72. package/dist/recovery/infrastructure/fileRecoveryStore.d.ts +21 -2
  73. package/dist/recovery/infrastructure/fileRecoveryStore.js +58 -6
  74. package/dist/recovery/infrastructure/fileRecoveryStore.js.map +1 -1
  75. package/dist/recovery/infrastructure/folderClaims.d.ts +27 -0
  76. package/dist/recovery/infrastructure/folderClaims.js +124 -0
  77. package/dist/recovery/infrastructure/folderClaims.js.map +1 -0
  78. package/dist/shared/domain/session.d.ts +12 -0
  79. package/dist/shared/domain/session.js +2 -0
  80. package/dist/shared/domain/session.js.map +1 -0
  81. package/dist/tools/domain/preparedCall.d.ts +5 -2
  82. package/dist/tools/domain/preparedCall.js +7 -1
  83. package/dist/tools/domain/preparedCall.js.map +1 -1
  84. package/dist/tools/domain/readRegistry.d.ts +11 -0
  85. package/dist/tools/domain/readRegistry.js.map +1 -1
  86. package/dist/tools/infrastructure/agentAsTool.d.ts +6 -0
  87. package/dist/tools/infrastructure/agentAsTool.js +9 -0
  88. package/dist/tools/infrastructure/agentAsTool.js.map +1 -1
  89. package/dist/tools/infrastructure/commandTool.js +6 -1
  90. package/dist/tools/infrastructure/commandTool.js.map +1 -1
  91. package/dist/tools/infrastructure/exploreTool.js +2 -0
  92. package/dist/tools/infrastructure/exploreTool.js.map +1 -1
  93. package/dist/tools/infrastructure/fileTools.d.ts +2 -0
  94. package/dist/tools/infrastructure/fileTools.js +10 -1
  95. package/dist/tools/infrastructure/fileTools.js.map +1 -1
  96. package/dist/tools/infrastructure/removeWorktreeTool.d.ts +23 -0
  97. package/dist/tools/infrastructure/removeWorktreeTool.js +178 -0
  98. package/dist/tools/infrastructure/removeWorktreeTool.js.map +1 -0
  99. package/dist/tools/infrastructure/subagents.d.ts +3 -1
  100. package/dist/tools/infrastructure/subagents.js +2 -1
  101. package/dist/tools/infrastructure/subagents.js.map +1 -1
  102. package/dist/tools/infrastructure/trackedEdits.js +1 -0
  103. package/dist/tools/infrastructure/trackedEdits.js.map +1 -1
  104. package/dist/tools/infrastructure/workspaceWrites.d.ts +17 -1
  105. package/dist/tools/infrastructure/workspaceWrites.js +48 -17
  106. package/dist/tools/infrastructure/workspaceWrites.js.map +1 -1
  107. package/dist/tools/infrastructure/worktrees.d.ts +49 -0
  108. package/dist/tools/infrastructure/worktrees.js +154 -0
  109. package/dist/tools/infrastructure/worktrees.js.map +1 -0
  110. package/dist/tools/infrastructure/writeCeiling.d.ts +16 -0
  111. package/dist/tools/infrastructure/writeCeiling.js +49 -0
  112. package/dist/tools/infrastructure/writeCeiling.js.map +1 -0
  113. package/dist/tools/infrastructure/writeSession.d.ts +5 -1
  114. package/dist/tools/infrastructure/writeSession.js +7 -3
  115. package/dist/tools/infrastructure/writeSession.js.map +1 -1
  116. package/dist/workflow/domain/workflow.d.ts +3 -0
  117. package/dist/workflow/domain/workflow.js.map +1 -1
  118. package/dist/workflow/domain/workflowRunner.js +8 -2
  119. package/dist/workflow/domain/workflowRunner.js.map +1 -1
  120. package/package.json +2 -1
@@ -1,9 +1,15 @@
1
+ import { sep } from 'node:path';
2
+ import { EditRefusedError } from "../domain/fileEdits.js";
1
3
  import { FileEditor } from "./fileEditor.js";
4
+ import { ceilingRefusal, realFolders } from "./writeCeiling.js";
2
5
  import { WriteSession } from "./writeSession.js";
3
6
  // One session per run and history on disk, whichever set of tools asks for it: two sets built
4
7
  // apart over one workspace, such as two stages of a workflow, record one run. Each keeps its own
5
8
  // policy. Kept by the run's context, so nothing here outlives a run.
6
9
  const sessions = new WeakMap();
10
+ function within(path, folder) {
11
+ return path === folder || path.startsWith(`${folder}${sep}`);
12
+ }
7
13
  /**
8
14
  * The writing side of a workspace, shared by every write tool built over it: one policy and
9
15
  * one recovery store. Every set of tools over the same history shares the run's `WriteSession`,
@@ -14,10 +20,26 @@ export class WorkspaceWrites {
14
20
  formatter;
15
21
  policy;
16
22
  store;
17
- constructor({ policy, store, formatter, }) {
23
+ writeRoots;
24
+ constructor({ policy, store, formatter, writeRoots = store.writeFolders, }) {
18
25
  this.policy = policy;
19
26
  this.store = store;
20
27
  this.formatter = formatter;
28
+ this.writeRoots = realFolders(writeRoots);
29
+ }
30
+ /**
31
+ * Checks, before anything is written or anyone is asked, that the agent that made the call
32
+ * may write wherever these tools may: an agent another agent started never writes more than
33
+ * that agent may.
34
+ *
35
+ * @throws {EditRefusedError} when these tools may write outside the call's `writeCeiling`.
36
+ */
37
+ async checkCeiling({ writeCeiling }) {
38
+ if (writeCeiling === undefined)
39
+ return;
40
+ const refusal = ceilingRefusal(await this.writeRoots, writeCeiling);
41
+ if (refusal !== undefined)
42
+ throw new EditRefusedError(refusal);
21
43
  }
22
44
  /** An editor over this run's session, under this set of tools' policy. */
23
45
  editorFor(context) {
@@ -27,6 +49,10 @@ export class WorkspaceWrites {
27
49
  * Everything this run writes to this history, its commands' changes included, from any set of
28
50
  * tools. Created on its first write, and registered before anything is awaited, so two tools
29
51
  * asking at once get the same one.
52
+ *
53
+ * @throws {EditRefusedError} for a set of tools that may write outside the folders of the set
54
+ * that started the session: the run claims those alone, so a worktree removed meanwhile could
55
+ * lose what it writes there.
30
56
  */
31
57
  sessionFor(context) {
32
58
  let ofRun = sessions.get(context);
@@ -34,23 +60,28 @@ export class WorkspaceWrites {
34
60
  ofRun = new Map();
35
61
  sessions.set(context, ofRun);
36
62
  }
37
- let session = ofRun.get(this.store.workspaceId);
38
- if (!session) {
39
- const created = new WriteSession({
40
- store: this.store,
41
- // So every turn of the run, in every role, can be tied to what it wrote.
42
- onStart: runId => {
43
- context.historyRun.runId = runId;
44
- },
45
- // A run that changed nothing is not in the history, so its turns wrote nothing.
46
- onDiscard: () => {
47
- delete context.historyRun.runId;
48
- },
49
- });
50
- context.onFinish(end => created.finish(end));
51
- ofRun.set(this.store.workspaceId, created);
52
- session = created;
63
+ const started = ofRun.get(this.store.workspaceId);
64
+ if (started) {
65
+ const outside = this.store.writeFolders.filter(folder => !started.folders.some(claimed => within(folder, claimed)));
66
+ if (outside.length > 0) {
67
+ throw new EditRefusedError(`This run already writes to this workspace's history with tools that may write only in ${started.folders.join(', ')}; tools that may also write in ${outside.join(', ')} cannot join it, so nothing was changed. It is a limit of the harness: in one run, every set of tools that writes to a history may write only where the first one may. Tell the user.`);
68
+ }
69
+ return started.session;
53
70
  }
71
+ const session = new WriteSession({
72
+ store: this.store,
73
+ session: context.session,
74
+ // So every turn of the run, in every role, can be tied to what it wrote.
75
+ onStart: runId => {
76
+ context.historyRun.runId = runId;
77
+ },
78
+ // A run that changed nothing is not in the history, so its turns wrote nothing.
79
+ onDiscard: () => {
80
+ delete context.historyRun.runId;
81
+ },
82
+ });
83
+ context.onFinish(end => session.finish(end));
84
+ ofRun.set(this.store.workspaceId, { session, folders: this.store.writeFolders });
54
85
  return session;
55
86
  }
56
87
  }
@@ -1 +1 @@
1
- {"version":3,"file":"workspaceWrites.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/workspaceWrites.ts"],"names":[],"mappings":"AAGA,OAAO,EAAE,UAAU,EAAE,MAAM,iBAAiB,CAAC;AAE7C,OAAO,EAAE,YAAY,EAAE,MAAM,mBAAmB,CAAC;AAEjD,8FAA8F;AAC9F,iGAAiG;AACjG,qEAAqE;AACrE,MAAM,QAAQ,GAAG,IAAI,OAAO,EAAyC,CAAC;AAEtE;;;;GAIG;AACH,MAAM,OAAO,eAAe;IAC3B,8FAA8F;IACrF,SAAS,CAA4B;IAC7B,MAAM,CAAe;IACrB,KAAK,CAAgB;IAEtC,YAAY,EACX,MAAM,EACN,KAAK,EACL,SAAS,GAKT;QACA,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;QACrB,IAAI,CAAC,KAAK,GAAG,KAAK,CAAC;QACnB,IAAI,CAAC,SAAS,GAAG,SAAS,CAAC;IAC5B,CAAC;IAED,0EAA0E;IAC1E,SAAS,CAAC,OAAmB;QAC5B,OAAO,IAAI,UAAU,CAAC,EAAE,MAAM,EAAE,IAAI,CAAC,MAAM,EAAE,OAAO,EAAE,IAAI,CAAC,UAAU,CAAC,OAAO,CAAC,EAAE,CAAC,CAAC;IACnF,CAAC;IAED;;;;OAIG;IACH,UAAU,CAAC,OAAmB;QAC7B,IAAI,KAAK,GAAG,QAAQ,CAAC,GAAG,CAAC,OAAO,CAAC,CAAC;QAClC,IAAI,CAAC,KAAK,EAAE,CAAC;YACZ,KAAK,GAAG,IAAI,GAAG,EAAE,CAAC;YAClB,QAAQ,CAAC,GAAG,CAAC,OAAO,EAAE,KAAK,CAAC,CAAC;QAC9B,CAAC;QACD,IAAI,OAAO,GAAG,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,WAAW,CAAC,CAAC;QAChD,IAAI,CAAC,OAAO,EAAE,CAAC;YACd,MAAM,OAAO,GAAG,IAAI,YAAY,CAAC;gBAChC,KAAK,EAAE,IAAI,CAAC,KAAK;gBACjB,yEAAyE;gBACzE,OAAO,EAAE,KAAK,CAAC,EAAE;oBAChB,OAAO,CAAC,UAAU,CAAC,KAAK,GAAG,KAAK,CAAC;gBAClC,CAAC;gBACD,gFAAgF;gBAChF,SAAS,EAAE,GAAG,EAAE;oBACf,OAAO,OAAO,CAAC,UAAU,CAAC,KAAK,CAAC;gBACjC,CAAC;aACD,CAAC,CAAC;YACH,OAAO,CAAC,QAAQ,CAAC,GAAG,CAAC,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,GAAG,CAAC,CAAC,CAAC;YAC7C,KAAK,CAAC,GAAG,CAAC,IAAI,CAAC,KAAK,CAAC,WAAW,EAAE,OAAO,CAAC,CAAC;YAC3C,OAAO,GAAG,OAAO,CAAC;QACnB,CAAC;QACD,OAAO,OAAO,CAAC;IAChB,CAAC;CACD","sourcesContent":["import type { RunContext } from '#src/agent/domain/runContext';\nimport type { RecoveryStore } from '#src/recovery/domain/recoveryStore';\nimport type { AccessPolicy } from '../domain/accessPolicy.ts';\nimport { FileEditor } from './fileEditor.ts';\nimport type { FileFormatter } from './fileFormatter.ts';\nimport { WriteSession } from './writeSession.ts';\n\n// One session per run and history on disk, whichever set of tools asks for it: two sets built\n// apart over one workspace, such as two stages of a workflow, record one run. Each keeps its own\n// policy. Kept by the run's context, so nothing here outlives a run.\nconst sessions = new WeakMap<RunContext, Map<string, WriteSession>>();\n\n/**\n * The writing side of a workspace, shared by every write tool built over it: one policy and\n * one recovery store. Every set of tools over the same history shares the run's `WriteSession`,\n * ended with that run.\n */\nexport class WorkspaceWrites {\n\t/** What the edit tools run on each file they create or change, when the consumer gave one. */\n\treadonly formatter: FileFormatter | undefined;\n\tprivate readonly policy: AccessPolicy;\n\tprivate readonly store: RecoveryStore;\n\n\tconstructor({\n\t\tpolicy,\n\t\tstore,\n\t\tformatter,\n\t}: {\n\t\tpolicy: AccessPolicy;\n\t\tstore: RecoveryStore;\n\t\tformatter?: FileFormatter;\n\t}) {\n\t\tthis.policy = policy;\n\t\tthis.store = store;\n\t\tthis.formatter = formatter;\n\t}\n\n\t/** An editor over this run's session, under this set of tools' policy. */\n\teditorFor(context: RunContext): FileEditor {\n\t\treturn new FileEditor({ policy: this.policy, session: this.sessionFor(context) });\n\t}\n\n\t/**\n\t * Everything this run writes to this history, its commands' changes included, from any set of\n\t * tools. Created on its first write, and registered before anything is awaited, so two tools\n\t * asking at once get the same one.\n\t */\n\tsessionFor(context: RunContext): WriteSession {\n\t\tlet ofRun = sessions.get(context);\n\t\tif (!ofRun) {\n\t\t\tofRun = new Map();\n\t\t\tsessions.set(context, ofRun);\n\t\t}\n\t\tlet session = ofRun.get(this.store.workspaceId);\n\t\tif (!session) {\n\t\t\tconst created = new WriteSession({\n\t\t\t\tstore: this.store,\n\t\t\t\t// So every turn of the run, in every role, can be tied to what it wrote.\n\t\t\t\tonStart: runId => {\n\t\t\t\t\tcontext.historyRun.runId = runId;\n\t\t\t\t},\n\t\t\t\t// A run that changed nothing is not in the history, so its turns wrote nothing.\n\t\t\t\tonDiscard: () => {\n\t\t\t\t\tdelete context.historyRun.runId;\n\t\t\t\t},\n\t\t\t});\n\t\t\tcontext.onFinish(end => created.finish(end));\n\t\t\tofRun.set(this.store.workspaceId, created);\n\t\t\tsession = created;\n\t\t}\n\t\treturn session;\n\t}\n}\n"]}
1
+ {"version":3,"file":"workspaceWrites.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/workspaceWrites.ts"],"names":[],"mappings":"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","sourcesContent":["import type { RunContext } from '#src/agent/domain/runContext';\nimport type { RecoveryStore } from '#src/recovery/domain/recoveryStore';\nimport type { AccessPolicy } from '../domain/accessPolicy.ts';\nimport { sep } from 'node:path';\nimport { EditRefusedError } from '../domain/fileEdits.ts';\nimport type { ToolCallContext } from '../domain/readRegistry.ts';\nimport { FileEditor } from './fileEditor.ts';\nimport type { FileFormatter } from './fileFormatter.ts';\nimport { ceilingRefusal, realFolders } from './writeCeiling.ts';\nimport { WriteSession } from './writeSession.ts';\n\n// One session per run and history on disk, whichever set of tools asks for it: two sets built\n// apart over one workspace, such as two stages of a workflow, record one run. Each keeps its own\n// policy. Kept by the run's context, so nothing here outlives a run.\nconst sessions = new WeakMap<\n\tRunContext,\n\tMap<string, { session: WriteSession; folders: readonly string[] }>\n>();\n\nfunction within(path: string, folder: string): boolean {\n\treturn path === folder || path.startsWith(`${folder}${sep}`);\n}\n\n/**\n * The writing side of a workspace, shared by every write tool built over it: one policy and\n * one recovery store. Every set of tools over the same history shares the run's `WriteSession`,\n * ended with that run.\n */\nexport class WorkspaceWrites {\n\t/** What the edit tools run on each file they create or change, when the consumer gave one. */\n\treadonly formatter: FileFormatter | undefined;\n\tprivate readonly policy: AccessPolicy;\n\tprivate readonly store: RecoveryStore;\n\tprivate readonly writeRoots: Promise<string[]>;\n\n\tconstructor({\n\t\tpolicy,\n\t\tstore,\n\t\tformatter,\n\t\twriteRoots = store.writeFolders,\n\t}: {\n\t\tpolicy: AccessPolicy;\n\t\tstore: RecoveryStore;\n\t\tformatter?: FileFormatter;\n\t\t/** The `write` roots of the tools' workspace. Defaults to the folders the store claims. */\n\t\twriteRoots?: readonly string[];\n\t}) {\n\t\tthis.policy = policy;\n\t\tthis.store = store;\n\t\tthis.formatter = formatter;\n\t\tthis.writeRoots = realFolders(writeRoots);\n\t}\n\n\t/**\n\t * Checks, before anything is written or anyone is asked, that the agent that made the call\n\t * may write wherever these tools may: an agent another agent started never writes more than\n\t * that agent may.\n\t *\n\t * @throws {EditRefusedError} when these tools may write outside the call's `writeCeiling`.\n\t */\n\tasync checkCeiling({ writeCeiling }: Pick<ToolCallContext, 'writeCeiling'>): Promise<void> {\n\t\tif (writeCeiling === undefined) return;\n\t\tconst refusal = ceilingRefusal(await this.writeRoots, writeCeiling);\n\t\tif (refusal !== undefined) throw new EditRefusedError(refusal);\n\t}\n\n\t/** An editor over this run's session, under this set of tools' policy. */\n\teditorFor(context: RunContext): FileEditor {\n\t\treturn new FileEditor({ policy: this.policy, session: this.sessionFor(context) });\n\t}\n\n\t/**\n\t * Everything this run writes to this history, its commands' changes included, from any set of\n\t * tools. Created on its first write, and registered before anything is awaited, so two tools\n\t * asking at once get the same one.\n\t *\n\t * @throws {EditRefusedError} for a set of tools that may write outside the folders of the set\n\t * that started the session: the run claims those alone, so a worktree removed meanwhile could\n\t * lose what it writes there.\n\t */\n\tsessionFor(context: RunContext): WriteSession {\n\t\tlet ofRun = sessions.get(context);\n\t\tif (!ofRun) {\n\t\t\tofRun = new Map();\n\t\t\tsessions.set(context, ofRun);\n\t\t}\n\t\tconst started = ofRun.get(this.store.workspaceId);\n\t\tif (started) {\n\t\t\tconst outside = this.store.writeFolders.filter(\n\t\t\t\tfolder => !started.folders.some(claimed => within(folder, claimed)),\n\t\t\t);\n\t\t\tif (outside.length > 0) {\n\t\t\t\tthrow new EditRefusedError(\n\t\t\t\t\t`This run already writes to this workspace's history with tools that may write only in ${started.folders.join(', ')}; tools that may also write in ${outside.join(', ')} cannot join it, so nothing was changed. It is a limit of the harness: in one run, every set of tools that writes to a history may write only where the first one may. Tell the user.`,\n\t\t\t\t);\n\t\t\t}\n\t\t\treturn started.session;\n\t\t}\n\t\tconst session = new WriteSession({\n\t\t\tstore: this.store,\n\t\t\tsession: context.session,\n\t\t\t// So every turn of the run, in every role, can be tied to what it wrote.\n\t\t\tonStart: runId => {\n\t\t\t\tcontext.historyRun.runId = runId;\n\t\t\t},\n\t\t\t// A run that changed nothing is not in the history, so its turns wrote nothing.\n\t\t\tonDiscard: () => {\n\t\t\t\tdelete context.historyRun.runId;\n\t\t\t},\n\t\t});\n\t\tcontext.onFinish(end => session.finish(end));\n\t\tofRun.set(this.store.workspaceId, { session, folders: this.store.writeFolders });\n\t\treturn session;\n\t}\n}\n"]}
@@ -0,0 +1,49 @@
1
+ /** Runs git in `cwd`, its output unaffected by the user's colour settings. */
2
+ export declare function git(args: string[], cwd: string, signal?: AbortSignal): Promise<import("./runProcess.ts").ProcessResult>;
3
+ /**
4
+ * Whether `git version` names a git new enough for worktrees: `git version 2.39.3 (Apple
5
+ * Git-146)` is, `git version 2.34.1` is not. An answer it cannot read is not.
6
+ */
7
+ export declare function supportsWorktrees(versionOutput: string): boolean;
8
+ /** One worktree of a repository, as `git worktree list` gives it. */
9
+ export interface ListedWorktree {
10
+ /** Its folder, as a real path. */
11
+ path: string;
12
+ /** The branch it has checked out; absent when its HEAD is detached. */
13
+ branch?: string;
14
+ locked: boolean;
15
+ }
16
+ /**
17
+ * Every worktree of the repository `cwd` belongs to, the main folder first.
18
+ *
19
+ * @throws {Error} when git is older than 2.36, or `cwd` is in no git repository.
20
+ */
21
+ export declare function listWorktrees(cwd: string, signal?: AbortSignal): Promise<ListedWorktree[]>;
22
+ /**
23
+ * Creates a git worktree of `repository` on a new branch, `branch`, started at `from`, so an
24
+ * agent can work there apart from other runs on the same repository: it is a folder of its own,
25
+ * with a history of runs of its own. Give its `path` as an agent's root.
26
+ *
27
+ * Without `path`, it goes in a folder beside the repository's main folder, named after it:
28
+ * `../<repository>-worktrees/<branch>`, any `/` in the branch made a `-`. A relative `path` is
29
+ * from the working directory. The harness never removes it; the user does, or an agent given
30
+ * `removeWorktree` when the user asks it to.
31
+ *
32
+ * @throws {UnrecoverableError} when git is older than 2.36, `repository` is in no git
33
+ * repository, `branch` is not a valid new branch or already exists, `from` names no commit, or
34
+ * the folder already exists and is not empty. A failure leaves no branch or folder it made
35
+ * behind.
36
+ */
37
+ export declare function createWorktree({ repository, branch, from, path, }: {
38
+ /** A folder of the repository: its main folder, or one of its worktrees. */
39
+ repository: string;
40
+ /** The new branch the worktree checks out. */
41
+ branch: string;
42
+ /** The revision the branch starts at, such as `main` or a commit. */
43
+ from: string;
44
+ /** Where the worktree goes. */
45
+ path?: string;
46
+ }): Promise<{
47
+ path: string;
48
+ branch: string;
49
+ }>;
@@ -0,0 +1,154 @@
1
+ import { mkdir, readdir, realpath, rmdir } from 'node:fs/promises';
2
+ import { basename, dirname, join, resolve } from 'node:path';
3
+ import { UnrecoverableError } from '#src/shared/domain/errors';
4
+ import { runProcess } from "./runProcess.js";
5
+ // Never cancelled: creating a worktree is short, and the consumer waits for it.
6
+ const uncancelled = new AbortController().signal;
7
+ /** Runs git in `cwd`, its output unaffected by the user's colour settings. */
8
+ export function git(args, cwd, signal = uncancelled) {
9
+ return runProcess('git', ['-c', 'color.ui=never', ...args], { cwd, signal });
10
+ }
11
+ function firstLine(text) {
12
+ return text.trim().split('\n')[0] ?? '';
13
+ }
14
+ // The first git whose `worktree list` takes `-z`, without which a path holding a newline is
15
+ // misread.
16
+ const oldestGit = [2, 36];
17
+ /**
18
+ * Whether `git version` names a git new enough for worktrees: `git version 2.39.3 (Apple
19
+ * Git-146)` is, `git version 2.34.1` is not. An answer it cannot read is not.
20
+ */
21
+ export function supportsWorktrees(versionOutput) {
22
+ const match = /^git version (\d+)\.(\d+)/.exec(versionOutput.trim());
23
+ if (!match)
24
+ return false;
25
+ const [major, minor] = [Number(match[1]), Number(match[2])];
26
+ return major > oldestGit[0] || (major === oldestGit[0] && minor >= oldestGit[1]);
27
+ }
28
+ /** Throws, naming the version found, unless this machine's git is new enough for worktrees. */
29
+ async function checkGit(cwd, signal) {
30
+ const { stdout, stderr, exitCode } = await git(['version'], cwd, signal);
31
+ if (exitCode !== 0)
32
+ throw new Error(`git could not be run: ${firstLine(stderr)}`);
33
+ if (!supportsWorktrees(stdout)) {
34
+ throw new Error(`worktrees need git ${oldestGit.join('.')} or later, and this machine has ${firstLine(stdout) || 'an unknown version'}`);
35
+ }
36
+ }
37
+ /**
38
+ * Every worktree of the repository `cwd` belongs to, the main folder first.
39
+ *
40
+ * @throws {Error} when git is older than 2.36, or `cwd` is in no git repository.
41
+ */
42
+ export async function listWorktrees(cwd, signal) {
43
+ await checkGit(cwd, signal);
44
+ const { stdout, stderr, exitCode } = await git(['worktree', 'list', '--porcelain', '-z'], cwd, signal);
45
+ if (exitCode !== 0)
46
+ throw new Error(`git could not list the worktrees: ${firstLine(stderr)}`);
47
+ const worktrees = [];
48
+ let current;
49
+ // One attribute per NUL-terminated field, and an empty field between worktrees.
50
+ for (const field of stdout.split('\0')) {
51
+ if (field.startsWith('worktree ')) {
52
+ current = { path: field.slice('worktree '.length), locked: false };
53
+ worktrees.push(current);
54
+ }
55
+ else if (current && field.startsWith('branch refs/heads/')) {
56
+ current.branch = field.slice('branch refs/heads/'.length);
57
+ }
58
+ else if (current && (field === 'locked' || field.startsWith('locked '))) {
59
+ current.locked = true;
60
+ }
61
+ }
62
+ // git prints the paths it was given; a link in one must not hide a match.
63
+ return Promise.all(worktrees.map(async (worktree) => ({
64
+ ...worktree,
65
+ path: await realpath(worktree.path).catch(() => worktree.path),
66
+ })));
67
+ }
68
+ /**
69
+ * Removes the branch this call made, once its worktree could not be added: only while it still
70
+ * points at `sha`, which `update-ref` checks as it deletes, and no worktree has it.
71
+ */
72
+ async function removeOwnBranch(cwd, branch, sha) {
73
+ const worktrees = await listWorktrees(cwd).catch(() => undefined);
74
+ if (!worktrees?.every(worktree => worktree.branch !== branch))
75
+ return;
76
+ await git(['update-ref', '-d', `refs/heads/${branch}`, sha], cwd);
77
+ }
78
+ function failed(what, cause) {
79
+ return new UnrecoverableError(`The worktree could not be created: ${what}`, { cause });
80
+ }
81
+ /**
82
+ * Creates a git worktree of `repository` on a new branch, `branch`, started at `from`, so an
83
+ * agent can work there apart from other runs on the same repository: it is a folder of its own,
84
+ * with a history of runs of its own. Give its `path` as an agent's root.
85
+ *
86
+ * Without `path`, it goes in a folder beside the repository's main folder, named after it:
87
+ * `../<repository>-worktrees/<branch>`, any `/` in the branch made a `-`. A relative `path` is
88
+ * from the working directory. The harness never removes it; the user does, or an agent given
89
+ * `removeWorktree` when the user asks it to.
90
+ *
91
+ * @throws {UnrecoverableError} when git is older than 2.36, `repository` is in no git
92
+ * repository, `branch` is not a valid new branch or already exists, `from` names no commit, or
93
+ * the folder already exists and is not empty. A failure leaves no branch or folder it made
94
+ * behind.
95
+ */
96
+ export async function createWorktree({ repository, branch, from, path, }) {
97
+ const cwd = resolve(repository);
98
+ // First: an older git could make the branch and then fail to list the worktrees.
99
+ await checkGit(cwd).catch((error) => {
100
+ throw failed(error instanceof Error ? error.message : String(error), 'Update git.');
101
+ });
102
+ const valid = await git(['check-ref-format', '--branch', branch], cwd);
103
+ if (valid.exitCode !== 0) {
104
+ throw failed(`"${branch}" is not a valid branch name`, firstLine(valid.stderr) || 'git refused it.');
105
+ }
106
+ // Resolved first, so a revision that starts with a dash can never be read as an option.
107
+ const commit = await git(['rev-parse', '--verify', '--quiet', '--end-of-options', `${from}^{commit}`], cwd);
108
+ if (commit.exitCode !== 0) {
109
+ throw failed(`"${from}" names no commit`, firstLine(commit.stderr) || 'git found no such revision.');
110
+ }
111
+ const sha = commit.stdout.trim();
112
+ const ref = `refs/heads/${branch}`;
113
+ if ((await git(['show-ref', '--verify', '--quiet', ref], cwd)).exitCode === 0) {
114
+ throw failed(`the branch "${branch}" already exists`, 'Name a new branch.');
115
+ }
116
+ let target;
117
+ if (path === undefined) {
118
+ const [main] = await listWorktrees(cwd).catch((error) => {
119
+ // git's own words: no repository is one cause among others.
120
+ throw failed(error instanceof Error ? error.message : String(error), `In ${cwd}.`);
121
+ });
122
+ if (!main)
123
+ throw failed(`${cwd} is in no git repository`, 'git listed no worktree.');
124
+ target = join(dirname(main.path), `${basename(main.path)}-worktrees`, branch.replaceAll('/', '-'));
125
+ }
126
+ else {
127
+ target = resolve(path);
128
+ }
129
+ // git takes an empty folder, but would create the branch before refusing a full one.
130
+ const held = await readdir(target).catch((error) => error.code === 'ENOENT' ? [] : undefined);
131
+ if (held === undefined || held.length > 0) {
132
+ throw failed(`${target} already exists`, 'Give the worktree a folder that does not.');
133
+ }
134
+ // The first folder this call made, if any, which it removes again should git fail.
135
+ const made = path === undefined ? await mkdir(dirname(target), { recursive: true }) : undefined;
136
+ const removeMade = async () => {
137
+ if (made !== undefined)
138
+ await rmdir(made).catch(() => undefined);
139
+ };
140
+ // Made apart and first: git refuses a branch that exists, so the one made here is this call's.
141
+ const created = await git(['branch', '--no-track', branch, sha], cwd);
142
+ if (created.exitCode !== 0) {
143
+ await removeMade();
144
+ throw failed(`the branch "${branch}" could not be made`, firstLine(created.stderr));
145
+ }
146
+ const added = await git(['worktree', 'add', '--', target, branch], cwd);
147
+ if (added.exitCode !== 0) {
148
+ await removeOwnBranch(cwd, branch, sha);
149
+ await removeMade();
150
+ throw failed('git refused it', firstLine(added.stderr));
151
+ }
152
+ return { path: await realpath(target), branch };
153
+ }
154
+ //# sourceMappingURL=worktrees.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"worktrees.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/worktrees.ts"],"names":[],"mappings":"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","sourcesContent":["import { mkdir, readdir, realpath, rmdir } from 'node:fs/promises';\nimport { basename, dirname, join, resolve } from 'node:path';\nimport { UnrecoverableError } from '#src/shared/domain/errors';\nimport { runProcess } from './runProcess.ts';\n\n// Never cancelled: creating a worktree is short, and the consumer waits for it.\nconst uncancelled = new AbortController().signal;\n\n/** Runs git in `cwd`, its output unaffected by the user's colour settings. */\nexport function git(args: string[], cwd: string, signal: AbortSignal = uncancelled) {\n\treturn runProcess('git', ['-c', 'color.ui=never', ...args], { cwd, signal });\n}\n\nfunction firstLine(text: string): string {\n\treturn text.trim().split('\\n')[0] ?? '';\n}\n\n// The first git whose `worktree list` takes `-z`, without which a path holding a newline is\n// misread.\nconst oldestGit = [2, 36] as const;\n\n/**\n * Whether `git version` names a git new enough for worktrees: `git version 2.39.3 (Apple\n * Git-146)` is, `git version 2.34.1` is not. An answer it cannot read is not.\n */\nexport function supportsWorktrees(versionOutput: string): boolean {\n\tconst match = /^git version (\\d+)\\.(\\d+)/.exec(versionOutput.trim());\n\tif (!match) return false;\n\tconst [major, minor] = [Number(match[1]), Number(match[2])];\n\treturn major > oldestGit[0] || (major === oldestGit[0] && minor >= oldestGit[1]);\n}\n\n/** Throws, naming the version found, unless this machine's git is new enough for worktrees. */\nasync function checkGit(cwd: string, signal?: AbortSignal): Promise<void> {\n\tconst { stdout, stderr, exitCode } = await git(['version'], cwd, signal);\n\tif (exitCode !== 0) throw new Error(`git could not be run: ${firstLine(stderr)}`);\n\tif (!supportsWorktrees(stdout)) {\n\t\tthrow new Error(\n\t\t\t`worktrees need git ${oldestGit.join('.')} or later, and this machine has ${firstLine(stdout) || 'an unknown version'}`,\n\t\t);\n\t}\n}\n\n/** One worktree of a repository, as `git worktree list` gives it. */\nexport interface ListedWorktree {\n\t/** Its folder, as a real path. */\n\tpath: string;\n\t/** The branch it has checked out; absent when its HEAD is detached. */\n\tbranch?: string;\n\tlocked: boolean;\n}\n\n/**\n * Every worktree of the repository `cwd` belongs to, the main folder first.\n *\n * @throws {Error} when git is older than 2.36, or `cwd` is in no git repository.\n */\nexport async function listWorktrees(cwd: string, signal?: AbortSignal): Promise<ListedWorktree[]> {\n\tawait checkGit(cwd, signal);\n\tconst { stdout, stderr, exitCode } = await git(\n\t\t['worktree', 'list', '--porcelain', '-z'],\n\t\tcwd,\n\t\tsignal,\n\t);\n\tif (exitCode !== 0) throw new Error(`git could not list the worktrees: ${firstLine(stderr)}`);\n\tconst worktrees: ListedWorktree[] = [];\n\tlet current: ListedWorktree | undefined;\n\t// One attribute per NUL-terminated field, and an empty field between worktrees.\n\tfor (const field of stdout.split('\\0')) {\n\t\tif (field.startsWith('worktree ')) {\n\t\t\tcurrent = { path: field.slice('worktree '.length), locked: false };\n\t\t\tworktrees.push(current);\n\t\t} else if (current && field.startsWith('branch refs/heads/')) {\n\t\t\tcurrent.branch = field.slice('branch refs/heads/'.length);\n\t\t} else if (current && (field === 'locked' || field.startsWith('locked '))) {\n\t\t\tcurrent.locked = true;\n\t\t}\n\t}\n\t// git prints the paths it was given; a link in one must not hide a match.\n\treturn Promise.all(\n\t\tworktrees.map(async worktree => ({\n\t\t\t...worktree,\n\t\t\tpath: await realpath(worktree.path).catch(() => worktree.path),\n\t\t})),\n\t);\n}\n\n/**\n * Removes the branch this call made, once its worktree could not be added: only while it still\n * points at `sha`, which `update-ref` checks as it deletes, and no worktree has it.\n */\nasync function removeOwnBranch(cwd: string, branch: string, sha: string): Promise<void> {\n\tconst worktrees = await listWorktrees(cwd).catch(() => undefined);\n\tif (!worktrees?.every(worktree => worktree.branch !== branch)) return;\n\tawait git(['update-ref', '-d', `refs/heads/${branch}`, sha], cwd);\n}\n\nfunction failed(what: string, cause: string): UnrecoverableError {\n\treturn new UnrecoverableError(`The worktree could not be created: ${what}`, { cause });\n}\n\n/**\n * Creates a git worktree of `repository` on a new branch, `branch`, started at `from`, so an\n * agent can work there apart from other runs on the same repository: it is a folder of its own,\n * with a history of runs of its own. Give its `path` as an agent's root.\n *\n * Without `path`, it goes in a folder beside the repository's main folder, named after it:\n * `../<repository>-worktrees/<branch>`, any `/` in the branch made a `-`. A relative `path` is\n * from the working directory. The harness never removes it; the user does, or an agent given\n * `removeWorktree` when the user asks it to.\n *\n * @throws {UnrecoverableError} when git is older than 2.36, `repository` is in no git\n * repository, `branch` is not a valid new branch or already exists, `from` names no commit, or\n * the folder already exists and is not empty. A failure leaves no branch or folder it made\n * behind.\n */\nexport async function createWorktree({\n\trepository,\n\tbranch,\n\tfrom,\n\tpath,\n}: {\n\t/** A folder of the repository: its main folder, or one of its worktrees. */\n\trepository: string;\n\t/** The new branch the worktree checks out. */\n\tbranch: string;\n\t/** The revision the branch starts at, such as `main` or a commit. */\n\tfrom: string;\n\t/** Where the worktree goes. */\n\tpath?: string;\n}): Promise<{ path: string; branch: string }> {\n\tconst cwd = resolve(repository);\n\t// First: an older git could make the branch and then fail to list the worktrees.\n\tawait checkGit(cwd).catch((error: unknown) => {\n\t\tthrow failed(error instanceof Error ? error.message : String(error), 'Update git.');\n\t});\n\tconst valid = await git(['check-ref-format', '--branch', branch], cwd);\n\tif (valid.exitCode !== 0) {\n\t\tthrow failed(\n\t\t\t`\"${branch}\" is not a valid branch name`,\n\t\t\tfirstLine(valid.stderr) || 'git refused it.',\n\t\t);\n\t}\n\t// Resolved first, so a revision that starts with a dash can never be read as an option.\n\tconst commit = await git(\n\t\t['rev-parse', '--verify', '--quiet', '--end-of-options', `${from}^{commit}`],\n\t\tcwd,\n\t);\n\tif (commit.exitCode !== 0) {\n\t\tthrow failed(\n\t\t\t`\"${from}\" names no commit`,\n\t\t\tfirstLine(commit.stderr) || 'git found no such revision.',\n\t\t);\n\t}\n\n\tconst sha = commit.stdout.trim();\n\tconst ref = `refs/heads/${branch}`;\n\tif ((await git(['show-ref', '--verify', '--quiet', ref], cwd)).exitCode === 0) {\n\t\tthrow failed(`the branch \"${branch}\" already exists`, 'Name a new branch.');\n\t}\n\n\tlet target: string;\n\tif (path === undefined) {\n\t\tconst [main] = await listWorktrees(cwd).catch((error: unknown) => {\n\t\t\t// git's own words: no repository is one cause among others.\n\t\t\tthrow failed(error instanceof Error ? error.message : String(error), `In ${cwd}.`);\n\t\t});\n\t\tif (!main) throw failed(`${cwd} is in no git repository`, 'git listed no worktree.');\n\t\ttarget = join(\n\t\t\tdirname(main.path),\n\t\t\t`${basename(main.path)}-worktrees`,\n\t\t\tbranch.replaceAll('/', '-'),\n\t\t);\n\t} else {\n\t\ttarget = resolve(path);\n\t}\n\t// git takes an empty folder, but would create the branch before refusing a full one.\n\tconst held = await readdir(target).catch((error: unknown) =>\n\t\t(error as NodeJS.ErrnoException).code === 'ENOENT' ? [] : undefined,\n\t);\n\tif (held === undefined || held.length > 0) {\n\t\tthrow failed(`${target} already exists`, 'Give the worktree a folder that does not.');\n\t}\n\t// The first folder this call made, if any, which it removes again should git fail.\n\tconst made = path === undefined ? await mkdir(dirname(target), { recursive: true }) : undefined;\n\tconst removeMade = async () => {\n\t\tif (made !== undefined) await rmdir(made).catch(() => undefined);\n\t};\n\n\t// Made apart and first: git refuses a branch that exists, so the one made here is this call's.\n\tconst created = await git(['branch', '--no-track', branch, sha], cwd);\n\tif (created.exitCode !== 0) {\n\t\tawait removeMade();\n\t\tthrow failed(`the branch \"${branch}\" could not be made`, firstLine(created.stderr));\n\t}\n\tconst added = await git(['worktree', 'add', '--', target, branch], cwd);\n\tif (added.exitCode !== 0) {\n\t\tawait removeOwnBranch(cwd, branch, sha);\n\t\tawait removeMade();\n\t\tthrow failed('git refused it', firstLine(added.stderr));\n\t}\n\treturn { path: await realpath(target), branch };\n}\n"]}
@@ -0,0 +1,16 @@
1
+ import type { ToolCallContext } from '../domain/readRegistry.ts';
2
+ /** Each folder as a real path, or resolved when it does not exist. */
3
+ export declare function realFolders(folders: readonly string[]): Promise<string[]>;
4
+ /** The folders of `folders` that lie in none of `ceiling`'s. */
5
+ export declare function outsideCeiling(folders: readonly string[], ceiling: readonly string[]): string[];
6
+ /**
7
+ * Where an agent the call starts may write, as real paths: within the call's own ceiling, and
8
+ * within the `write` roots of the agent that made it. Absent only when neither sets a limit, as
9
+ * for an agent with no workspace that no other agent started.
10
+ */
11
+ export declare function delegateCeiling(call: Pick<ToolCallContext, 'writeCeiling' | 'agentWriteRoots'>): Promise<readonly string[] | undefined>;
12
+ /**
13
+ * Why a set of tools that may write in `folders` cannot write under `ceiling`, for the model, or
14
+ * `undefined` when it can.
15
+ */
16
+ export declare function ceilingRefusal(folders: readonly string[], ceiling: readonly string[] | undefined): string | undefined;
@@ -0,0 +1,49 @@
1
+ import { realpath } from 'node:fs/promises';
2
+ import { resolve, sep } from 'node:path';
3
+ function within(path, folder) {
4
+ return path === folder || path.startsWith(`${folder}${sep}`);
5
+ }
6
+ /** Each folder as a real path, or resolved when it does not exist. */
7
+ export function realFolders(folders) {
8
+ return Promise.all(folders.map(folder => realpath(folder).catch(() => resolve(folder))));
9
+ }
10
+ /** The folders of `folders` that lie in none of `ceiling`'s. */
11
+ export function outsideCeiling(folders, ceiling) {
12
+ return folders.filter(folder => !ceiling.some(allowed => within(folder, allowed)));
13
+ }
14
+ /**
15
+ * Where an agent the call starts may write, as real paths: within the call's own ceiling, and
16
+ * within the `write` roots of the agent that made it. Absent only when neither sets a limit, as
17
+ * for an agent with no workspace that no other agent started.
18
+ */
19
+ export async function delegateCeiling(call) {
20
+ const { writeCeiling, agentWriteRoots } = call;
21
+ if (agentWriteRoots === undefined)
22
+ return writeCeiling;
23
+ const own = await realFolders(agentWriteRoots);
24
+ if (writeCeiling === undefined)
25
+ return own;
26
+ // Each folder both allow: the narrower one wherever one lies in the other.
27
+ return [
28
+ ...new Set([
29
+ ...own.filter(folder => writeCeiling.some(allowed => within(folder, allowed))),
30
+ ...writeCeiling.filter(folder => own.some(allowed => within(folder, allowed))),
31
+ ]),
32
+ ];
33
+ }
34
+ /**
35
+ * Why a set of tools that may write in `folders` cannot write under `ceiling`, for the model, or
36
+ * `undefined` when it can.
37
+ */
38
+ export function ceilingRefusal(folders, ceiling) {
39
+ if (ceiling === undefined)
40
+ return undefined;
41
+ const outside = outsideCeiling(folders, ceiling);
42
+ if (outside.length === 0)
43
+ return undefined;
44
+ const allowed = ceiling.length === 0
45
+ ? 'the agent that handed you this task only reads, and your tools may write in'
46
+ : `the agent that handed you this task may write only in ${ceiling.join(', ')}, and your tools may also write in`;
47
+ return `Nothing was changed: ${allowed} ${outside.join(', ')}. An agent another agent started never writes more than that agent may. Answer with what you found, and say what you would have changed.`;
48
+ }
49
+ //# sourceMappingURL=writeCeiling.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"writeCeiling.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/writeCeiling.ts"],"names":[],"mappings":"AAAA,OAAO,EAAE,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAC5C,OAAO,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,WAAW,CAAC;AAGzC,SAAS,MAAM,CAAC,IAAY,EAAE,MAAc;IAC3C,OAAO,IAAI,KAAK,MAAM,IAAI,IAAI,CAAC,UAAU,CAAC,GAAG,MAAM,GAAG,GAAG,EAAE,CAAC,CAAC;AAC9D,CAAC;AAED,sEAAsE;AACtE,MAAM,UAAU,WAAW,CAAC,OAA0B;IACrD,OAAO,OAAO,CAAC,GAAG,CAAC,OAAO,CAAC,GAAG,CAAC,MAAM,CAAC,EAAE,CAAC,QAAQ,CAAC,MAAM,CAAC,CAAC,KAAK,CAAC,GAAG,EAAE,CAAC,OAAO,CAAC,MAAM,CAAC,CAAC,CAAC,CAAC,CAAC;AAC1F,CAAC;AAED,gEAAgE;AAChE,MAAM,UAAU,cAAc,CAAC,OAA0B,EAAE,OAA0B;IACpF,OAAO,OAAO,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,CAAC,OAAO,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC,CAAC;AACpF,CAAC;AAED;;;;GAIG;AACH,MAAM,CAAC,KAAK,UAAU,eAAe,CACpC,IAA+D;IAE/D,MAAM,EAAE,YAAY,EAAE,eAAe,EAAE,GAAG,IAAI,CAAC;IAC/C,IAAI,eAAe,KAAK,SAAS;QAAE,OAAO,YAAY,CAAC;IACvD,MAAM,GAAG,GAAG,MAAM,WAAW,CAAC,eAAe,CAAC,CAAC;IAC/C,IAAI,YAAY,KAAK,SAAS;QAAE,OAAO,GAAG,CAAC;IAC3C,2EAA2E;IAC3E,OAAO;QACN,GAAG,IAAI,GAAG,CAAC;YACV,GAAG,GAAG,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,YAAY,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;YAC9E,GAAG,YAAY,CAAC,MAAM,CAAC,MAAM,CAAC,EAAE,CAAC,GAAG,CAAC,IAAI,CAAC,OAAO,CAAC,EAAE,CAAC,MAAM,CAAC,MAAM,EAAE,OAAO,CAAC,CAAC,CAAC;SAC9E,CAAC;KACF,CAAC;AACH,CAAC;AAED;;;GAGG;AACH,MAAM,UAAU,cAAc,CAC7B,OAA0B,EAC1B,OAAsC;IAEtC,IAAI,OAAO,KAAK,SAAS;QAAE,OAAO,SAAS,CAAC;IAC5C,MAAM,OAAO,GAAG,cAAc,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;IACjD,IAAI,OAAO,CAAC,MAAM,KAAK,CAAC;QAAE,OAAO,SAAS,CAAC;IAC3C,MAAM,OAAO,GACZ,OAAO,CAAC,MAAM,KAAK,CAAC;QACnB,CAAC,CAAC,6EAA6E;QAC/E,CAAC,CAAC,yDAAyD,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,oCAAoC,CAAC;IACpH,OAAO,wBAAwB,OAAO,IAAI,OAAO,CAAC,IAAI,CAAC,IAAI,CAAC,0IAA0I,CAAC;AACxM,CAAC","sourcesContent":["import { realpath } from 'node:fs/promises';\nimport { resolve, sep } from 'node:path';\nimport type { ToolCallContext } from '../domain/readRegistry.ts';\n\nfunction within(path: string, folder: string): boolean {\n\treturn path === folder || path.startsWith(`${folder}${sep}`);\n}\n\n/** Each folder as a real path, or resolved when it does not exist. */\nexport function realFolders(folders: readonly string[]): Promise<string[]> {\n\treturn Promise.all(folders.map(folder => realpath(folder).catch(() => resolve(folder))));\n}\n\n/** The folders of `folders` that lie in none of `ceiling`'s. */\nexport function outsideCeiling(folders: readonly string[], ceiling: readonly string[]): string[] {\n\treturn folders.filter(folder => !ceiling.some(allowed => within(folder, allowed)));\n}\n\n/**\n * Where an agent the call starts may write, as real paths: within the call's own ceiling, and\n * within the `write` roots of the agent that made it. Absent only when neither sets a limit, as\n * for an agent with no workspace that no other agent started.\n */\nexport async function delegateCeiling(\n\tcall: Pick<ToolCallContext, 'writeCeiling' | 'agentWriteRoots'>,\n): Promise<readonly string[] | undefined> {\n\tconst { writeCeiling, agentWriteRoots } = call;\n\tif (agentWriteRoots === undefined) return writeCeiling;\n\tconst own = await realFolders(agentWriteRoots);\n\tif (writeCeiling === undefined) return own;\n\t// Each folder both allow: the narrower one wherever one lies in the other.\n\treturn [\n\t\t...new Set([\n\t\t\t...own.filter(folder => writeCeiling.some(allowed => within(folder, allowed))),\n\t\t\t...writeCeiling.filter(folder => own.some(allowed => within(folder, allowed))),\n\t\t]),\n\t];\n}\n\n/**\n * Why a set of tools that may write in `folders` cannot write under `ceiling`, for the model, or\n * `undefined` when it can.\n */\nexport function ceilingRefusal(\n\tfolders: readonly string[],\n\tceiling: readonly string[] | undefined,\n): string | undefined {\n\tif (ceiling === undefined) return undefined;\n\tconst outside = outsideCeiling(folders, ceiling);\n\tif (outside.length === 0) return undefined;\n\tconst allowed =\n\t\tceiling.length === 0\n\t\t\t? 'the agent that handed you this task only reads, and your tools may write in'\n\t\t\t: `the agent that handed you this task may write only in ${ceiling.join(', ')}, and your tools may also write in`;\n\treturn `Nothing was changed: ${allowed} ${outside.join(', ')}. An agent another agent started never writes more than that agent may. Answer with what you found, and say what you would have changed.`;\n}\n"]}
@@ -1,3 +1,4 @@
1
+ import type { Session } from '#src/shared/domain/session';
1
2
  import { type RecoveryStore, type RunJournal, type RunStatus } from '#src/recovery/domain/recoveryStore';
2
3
  /**
3
4
  * Everything one top-level run writes, across all its roles and rounds. Its journal starts on
@@ -10,17 +11,20 @@ export declare class WriteSession {
10
11
  private readonly maxChanges;
11
12
  private readonly onStart;
12
13
  private readonly onDiscard;
14
+ private readonly session;
13
15
  private journal;
14
16
  private changes;
15
17
  private stopped;
16
18
  private writing;
17
- constructor({ store, maxChanges, onStart, onDiscard, }: {
19
+ constructor({ store, maxChanges, onStart, onDiscard, session, }: {
18
20
  store: RecoveryStore;
19
21
  maxChanges?: number;
20
22
  /** Told the run's id once its journal has started, before its first change is recorded. */
21
23
  onStart?: (runId: string) => void;
22
24
  /** Told when the run ended having changed nothing, so the history no longer has it. */
23
25
  onDiscard?: () => void;
26
+ /** The session the run belongs to, recorded with it. */
27
+ session?: Session;
24
28
  });
25
29
  /** The journal for one more change, counted against the run's limit. */
26
30
  journalForChange(): Promise<RunJournal>;
@@ -13,16 +13,18 @@ export class WriteSession {
13
13
  maxChanges;
14
14
  onStart;
15
15
  onDiscard;
16
+ session;
16
17
  journal;
17
18
  changes = 0;
18
19
  stopped;
19
20
  // The end of the last write waiting or running, which the next one starts after.
20
21
  writing = Promise.resolve();
21
- constructor({ store, maxChanges = defaultMaxChanges, onStart, onDiscard, }) {
22
+ constructor({ store, maxChanges = defaultMaxChanges, onStart, onDiscard, session, }) {
22
23
  this.store = store;
23
24
  this.maxChanges = maxChanges;
24
25
  this.onStart = onStart;
25
26
  this.onDiscard = onDiscard;
27
+ this.session = session;
26
28
  }
27
29
  /** The journal for one more change, counted against the run's limit. */
28
30
  async journalForChange() {
@@ -31,7 +33,9 @@ export class WriteSession {
31
33
  if (this.changes >= this.maxChanges) {
32
34
  throw new EditRefusedError(`This run has made ${String(this.maxChanges)} changes, the most one run may make`);
33
35
  }
34
- this.journal ??= this.store.startRun().then(started => {
36
+ this.journal ??= this.store
37
+ .startRun(this.session ? { session: this.session } : {})
38
+ .then(started => {
35
39
  this.onStart?.(started.runId);
36
40
  return started;
37
41
  });
@@ -43,7 +47,7 @@ export class WriteSession {
43
47
  // A failed start is not kept, so the next change tries again.
44
48
  this.journal = undefined;
45
49
  if (error instanceof WorkspaceBusyError) {
46
- throw new EditRefusedError('Another run, or a move through its history, holds this workspace, so nothing was changed', {
50
+ throw new EditRefusedError('Another run, a move through its history, or the removal of its worktree holds this workspace, so nothing was changed. The harness lets one run at a time write to a workspace; tell the user, who can wait for it to end or give this chat a worktree of its own.', {
47
51
  cause: error,
48
52
  });
49
53
  }
@@ -1 +1 @@
1
- {"version":3,"file":"writeSession.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/writeSession.ts"],"names":[],"mappings":"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","sourcesContent":["import {\n\ttype RecoveryStore,\n\ttype RunJournal,\n\ttype RunStatus,\n\tWorkspaceBusyError,\n} from '#src/recovery/domain/recoveryStore';\nimport { EditRefusedError } from '../domain/fileEdits.ts';\n\n// Far more than a task needs; a run past it is more likely looping than working.\nconst defaultMaxChanges = 500;\n\n/**\n * Everything one top-level run writes, across all its roles and rounds. Its journal starts on\n * the first write, so a run that only reads never holds the workspace, and ends with the run.\n * Once a change could not be confirmed, the session refuses every further write: the record of\n * what the run did must stay complete.\n */\nexport class WriteSession {\n\tprivate readonly store: RecoveryStore;\n\tprivate readonly maxChanges: number;\n\tprivate readonly onStart: ((runId: string) => void) | undefined;\n\tprivate readonly onDiscard: (() => void) | undefined;\n\tprivate journal: Promise<RunJournal> | undefined;\n\tprivate changes = 0;\n\tprivate stopped: string | undefined;\n\t// The end of the last write waiting or running, which the next one starts after.\n\tprivate writing: Promise<unknown> = Promise.resolve();\n\n\tconstructor({\n\t\tstore,\n\t\tmaxChanges = defaultMaxChanges,\n\t\tonStart,\n\t\tonDiscard,\n\t}: {\n\t\tstore: RecoveryStore;\n\t\tmaxChanges?: number;\n\t\t/** Told the run's id once its journal has started, before its first change is recorded. */\n\t\tonStart?: (runId: string) => void;\n\t\t/** Told when the run ended having changed nothing, so the history no longer has it. */\n\t\tonDiscard?: () => void;\n\t}) {\n\t\tthis.store = store;\n\t\tthis.maxChanges = maxChanges;\n\t\tthis.onStart = onStart;\n\t\tthis.onDiscard = onDiscard;\n\t}\n\n\t/** The journal for one more change, counted against the run's limit. */\n\tasync journalForChange(): Promise<RunJournal> {\n\t\tif (this.stopped !== undefined) throw new EditRefusedError(this.stopped);\n\t\tif (this.changes >= this.maxChanges) {\n\t\t\tthrow new EditRefusedError(\n\t\t\t\t`This run has made ${String(this.maxChanges)} changes, the most one run may make`,\n\t\t\t);\n\t\t}\n\n\t\tthis.journal ??= this.store.startRun().then(started => {\n\t\t\tthis.onStart?.(started.runId);\n\t\t\treturn started;\n\t\t});\n\t\tlet journal: RunJournal;\n\t\ttry {\n\t\t\tjournal = await this.journal;\n\t\t} catch (error) {\n\t\t\t// A failed start is not kept, so the next change tries again.\n\t\t\tthis.journal = undefined;\n\t\t\tif (error instanceof WorkspaceBusyError) {\n\t\t\t\tthrow new EditRefusedError(\n\t\t\t\t\t'Another run, or a move through its history, holds this workspace, so nothing was changed',\n\t\t\t\t\t{\n\t\t\t\t\t\tcause: error,\n\t\t\t\t\t},\n\t\t\t\t);\n\t\t\t}\n\t\t\tthrow new EditRefusedError(\n\t\t\t\t'The harness could not start recording this run, so nothing was changed',\n\t\t\t\t{ cause: error },\n\t\t\t);\n\t\t}\n\t\tthis.changes++;\n\t\treturn journal;\n\t}\n\n\t/**\n\t * Runs `write` once every write before it has ended, so agents of one run that work at once\n\t * never change files together: each change is read, recorded and made alone, and a command\n\t * runs with no other write while the files are captured around it. A cancellation stops the\n\t * wait.\n\t */\n\tasync exclusive<Result>(write: () => Promise<Result>, signal: AbortSignal): Promise<Result> {\n\t\tconst before = this.writing;\n\t\tlet release!: () => void;\n\t\tthis.writing = new Promise<void>(resolve => {\n\t\t\trelease = resolve;\n\t\t});\n\t\ttry {\n\t\t\tawait untilAborted(before, signal);\n\t\t\tsignal.throwIfAborted();\n\t\t\treturn await write();\n\t\t} finally {\n\t\t\t// Released even when cancelled while waiting: the writes after it wait for those before.\n\t\t\tvoid before.then(release, release);\n\t\t}\n\t}\n\n\t/** Refuses every further write, for this reason. The first reason given is kept. */\n\tstop(reason: string): void {\n\t\tthis.stopped ??= reason;\n\t}\n\n\t/** The run's id once it has written, which is what an undo needs. */\n\tasync runId(): Promise<string | undefined> {\n\t\tconst journal = await this.journal?.catch(() => undefined);\n\t\treturn journal?.runId;\n\t}\n\n\t/**\n\t * Ends the run's record and releases the workspace. A run that never wrote has nothing to end,\n\t * and one whose every change was abandoned leaves the history.\n\t */\n\tasync finish(status: Exclude<RunStatus, 'running' | 'interrupted'>): Promise<void> {\n\t\tconst journal = await this.journal?.catch(() => undefined);\n\t\tthis.journal = undefined;\n\t\tif (!journal) return;\n\t\ttry {\n\t\t\tawait journal.finish(status);\n\t\t} finally {\n\t\t\t// Even when finishing failed: a run that changed nothing is out of the history either way.\n\t\t\tif (!journal.changed) this.onDiscard?.();\n\t\t}\n\t}\n}\n\n/** Waits for `promise`, settled either way, or until `signal` aborts, whichever comes first. */\nfunction untilAborted(promise: Promise<unknown>, signal: AbortSignal): Promise<void> {\n\tif (signal.aborted) return Promise.resolve();\n\treturn new Promise(resolve => {\n\t\tconst done = () => {\n\t\t\tsignal.removeEventListener('abort', done);\n\t\t\tresolve();\n\t\t};\n\t\tsignal.addEventListener('abort', done, { once: true });\n\t\tpromise.then(done, done);\n\t});\n}\n"]}
1
+ {"version":3,"file":"writeSession.js","sourceRoot":"","sources":["../../../src/tools/infrastructure/writeSession.ts"],"names":[],"mappings":"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","sourcesContent":["import type { Session } from '#src/shared/domain/session';\nimport {\n\ttype RecoveryStore,\n\ttype RunJournal,\n\ttype RunStatus,\n\tWorkspaceBusyError,\n} from '#src/recovery/domain/recoveryStore';\nimport { EditRefusedError } from '../domain/fileEdits.ts';\n\n// Far more than a task needs; a run past it is more likely looping than working.\nconst defaultMaxChanges = 500;\n\n/**\n * Everything one top-level run writes, across all its roles and rounds. Its journal starts on\n * the first write, so a run that only reads never holds the workspace, and ends with the run.\n * Once a change could not be confirmed, the session refuses every further write: the record of\n * what the run did must stay complete.\n */\nexport class WriteSession {\n\tprivate readonly store: RecoveryStore;\n\tprivate readonly maxChanges: number;\n\tprivate readonly onStart: ((runId: string) => void) | undefined;\n\tprivate readonly onDiscard: (() => void) | undefined;\n\tprivate readonly session: Session | undefined;\n\tprivate journal: Promise<RunJournal> | undefined;\n\tprivate changes = 0;\n\tprivate stopped: string | undefined;\n\t// The end of the last write waiting or running, which the next one starts after.\n\tprivate writing: Promise<unknown> = Promise.resolve();\n\n\tconstructor({\n\t\tstore,\n\t\tmaxChanges = defaultMaxChanges,\n\t\tonStart,\n\t\tonDiscard,\n\t\tsession,\n\t}: {\n\t\tstore: RecoveryStore;\n\t\tmaxChanges?: number;\n\t\t/** Told the run's id once its journal has started, before its first change is recorded. */\n\t\tonStart?: (runId: string) => void;\n\t\t/** Told when the run ended having changed nothing, so the history no longer has it. */\n\t\tonDiscard?: () => void;\n\t\t/** The session the run belongs to, recorded with it. */\n\t\tsession?: Session;\n\t}) {\n\t\tthis.store = store;\n\t\tthis.maxChanges = maxChanges;\n\t\tthis.onStart = onStart;\n\t\tthis.onDiscard = onDiscard;\n\t\tthis.session = session;\n\t}\n\n\t/** The journal for one more change, counted against the run's limit. */\n\tasync journalForChange(): Promise<RunJournal> {\n\t\tif (this.stopped !== undefined) throw new EditRefusedError(this.stopped);\n\t\tif (this.changes >= this.maxChanges) {\n\t\t\tthrow new EditRefusedError(\n\t\t\t\t`This run has made ${String(this.maxChanges)} changes, the most one run may make`,\n\t\t\t);\n\t\t}\n\n\t\tthis.journal ??= this.store\n\t\t\t.startRun(this.session ? { session: this.session } : {})\n\t\t\t.then(started => {\n\t\t\t\tthis.onStart?.(started.runId);\n\t\t\t\treturn started;\n\t\t\t});\n\t\tlet journal: RunJournal;\n\t\ttry {\n\t\t\tjournal = await this.journal;\n\t\t} catch (error) {\n\t\t\t// A failed start is not kept, so the next change tries again.\n\t\t\tthis.journal = undefined;\n\t\t\tif (error instanceof WorkspaceBusyError) {\n\t\t\t\tthrow new EditRefusedError(\n\t\t\t\t\t'Another run, a move through its history, or the removal of its worktree holds this workspace, so nothing was changed. The harness lets one run at a time write to a workspace; tell the user, who can wait for it to end or give this chat a worktree of its own.',\n\t\t\t\t\t{\n\t\t\t\t\t\tcause: error,\n\t\t\t\t\t},\n\t\t\t\t);\n\t\t\t}\n\t\t\tthrow new EditRefusedError(\n\t\t\t\t'The harness could not start recording this run, so nothing was changed',\n\t\t\t\t{ cause: error },\n\t\t\t);\n\t\t}\n\t\tthis.changes++;\n\t\treturn journal;\n\t}\n\n\t/**\n\t * Runs `write` once every write before it has ended, so agents of one run that work at once\n\t * never change files together: each change is read, recorded and made alone, and a command\n\t * runs with no other write while the files are captured around it. A cancellation stops the\n\t * wait.\n\t */\n\tasync exclusive<Result>(write: () => Promise<Result>, signal: AbortSignal): Promise<Result> {\n\t\tconst before = this.writing;\n\t\tlet release!: () => void;\n\t\tthis.writing = new Promise<void>(resolve => {\n\t\t\trelease = resolve;\n\t\t});\n\t\ttry {\n\t\t\tawait untilAborted(before, signal);\n\t\t\tsignal.throwIfAborted();\n\t\t\treturn await write();\n\t\t} finally {\n\t\t\t// Released even when cancelled while waiting: the writes after it wait for those before.\n\t\t\tvoid before.then(release, release);\n\t\t}\n\t}\n\n\t/** Refuses every further write, for this reason. The first reason given is kept. */\n\tstop(reason: string): void {\n\t\tthis.stopped ??= reason;\n\t}\n\n\t/** The run's id once it has written, which is what an undo needs. */\n\tasync runId(): Promise<string | undefined> {\n\t\tconst journal = await this.journal?.catch(() => undefined);\n\t\treturn journal?.runId;\n\t}\n\n\t/**\n\t * Ends the run's record and releases the workspace. A run that never wrote has nothing to end,\n\t * and one whose every change was abandoned leaves the history.\n\t */\n\tasync finish(status: Exclude<RunStatus, 'running' | 'interrupted'>): Promise<void> {\n\t\tconst journal = await this.journal?.catch(() => undefined);\n\t\tthis.journal = undefined;\n\t\tif (!journal) return;\n\t\ttry {\n\t\t\tawait journal.finish(status);\n\t\t} finally {\n\t\t\t// Even when finishing failed: a run that changed nothing is out of the history either way.\n\t\t\tif (!journal.changed) this.onDiscard?.();\n\t\t}\n\t}\n}\n\n/** Waits for `promise`, settled either way, or until `signal` aborts, whichever comes first. */\nfunction untilAborted(promise: Promise<unknown>, signal: AbortSignal): Promise<void> {\n\tif (signal.aborted) return Promise.resolve();\n\treturn new Promise(resolve => {\n\t\tconst done = () => {\n\t\t\tsignal.removeEventListener('abort', done);\n\t\t\tresolve();\n\t\t};\n\t\tsignal.addEventListener('abort', done, { once: true });\n\t\tpromise.then(done, done);\n\t});\n}\n"]}
@@ -1,4 +1,5 @@
1
1
  import type { Agent, RunOptions } from '#src/agent/domain/agent';
2
+ import type { Session } from '#src/shared/domain/session';
2
3
  import type { ILogger } from '#src/shared/domain/logger';
3
4
  import type { Tokens } from '#src/shared/domain/tokens';
4
5
  import type { Validator } from '#src/shared/domain/validator';
@@ -80,6 +81,8 @@ export interface WorkflowResult<Result> {
80
81
  /** Seconds. */
81
82
  duration: number;
82
83
  runId?: string;
84
+ /** The session the run belonged to: the one `RunOptions.session` gave, or a new one. */
85
+ session?: Session;
83
86
  /** One report per stage run, in order: what each stage received, answered and chose. */
84
87
  stages: readonly WorkflowStageReport[];
85
88
  }
@@ -1 +1 @@
1
- {"version":3,"file":"workflow.js","sourceRoot":"","sources":["../../../src/workflow/domain/workflow.ts"],"names":[],"mappings":"AA0DA;;;;;;;GAOG;AACH,MAAM,UAAU,oBAAoB;IAOnC,OAAO,CAAC,IAAI,EAAE,UAAU,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,UAAU,EAAE,IAAI,EAAE,CAAC,CAAC;AACxD,CAAC","sourcesContent":["import type { Agent, RunOptions } from '#src/agent/domain/agent';\nimport type { ILogger } from '#src/shared/domain/logger';\nimport type { Tokens } from '#src/shared/domain/tokens';\nimport type { Validator } from '#src/shared/domain/validator';\n\n/** The names of a workflow's stages: the keys of its map of what each stage receives. */\nexport type WorkflowStageName<Inputs> = keyof Inputs & string;\n\n/**\n * What a stage chose once its answer was valid. `go` runs the stage it names, which receives\n * `input` and nothing else; `stop` ends the run early on purpose and `complete` ends it finished,\n * each with the run's result. `Inputs` maps each stage's name to what it receives, so a `go` that\n * names a stage that does not exist, or hands it the wrong input, does not compile.\n */\nexport type WorkflowTransition<Inputs, Result> =\n\t| {\n\t\t\t[Name in WorkflowStageName<Inputs>]: { type: 'go'; stage: Name; input: Inputs[Name] };\n\t }[WorkflowStageName<Inputs>]\n\t| { type: 'stop'; result: Result }\n\t| { type: 'complete'; result: Result };\n\n/**\n * One step of a workflow: how it creates its agent, the prompt that agent gets from the stage's\n * input, how its JSON answer is validated, and the transition the valid answer selects.\n */\nexport interface WorkflowStageDefinition<\n\tInputs,\n\tName extends WorkflowStageName<Inputs>,\n\tOutput,\n\tResult,\n> {\n\t/**\n\t * Creates the stage's agent, with a context of its own, the first time the stage runs in a\n\t * run. When the run comes back to the stage, the same agent goes on with its conversation;\n\t * the next run creates another. It may be async, as `createLLMOrchestrator` is.\n\t */\n\tagent(): Agent | PromiseLike<Agent>;\n\tprompt(input: Inputs[Name]): string | PromiseLike<string>;\n\t/** Receives the agent's answer parsed as JSON. */\n\treadonly validator: Validator<Output>;\n\t/** Receives the validated answer and the stage's own input, to carry forward what the next stage needs. */\n\ttransition(\n\t\toutput: Output,\n\t\tinput: Inputs[Name],\n\t): WorkflowTransition<Inputs, Result> | PromiseLike<WorkflowTransition<Inputs, Result>>;\n\t/** How many answers the stage may give before an invalid one fails the run. */\n\treadonly maxAttempts?: number;\n}\n\n/** A stage bound to its name, as `defineWorkflowStages` returns it. */\nexport interface WorkflowStage<\n\tInputs,\n\tResult,\n\tName extends WorkflowStageName<Inputs> = WorkflowStageName<Inputs>,\n> extends WorkflowStageDefinition<Inputs, Name, unknown, Result> {\n\treadonly name: Name;\n}\n\n/**\n * Binds stage definitions to one workflow's map of stage inputs and its result. The function it\n * returns infers each stage's output from its validator.\n *\n * @example\n * const stage = defineWorkflowStages<{ triage: string; draft: Brief }, string>();\n * const triage = stage('triage', { agent, prompt, validator, transition });\n */\nexport function defineWorkflowStages<Inputs, Result>(): <\n\tName extends WorkflowStageName<Inputs>,\n\tOutput,\n>(\n\tname: Name,\n\tdefinition: WorkflowStageDefinition<Inputs, Name, Output, Result>,\n) => WorkflowStage<Inputs, Result, Name> {\n\treturn (name, definition) => ({ ...definition, name });\n}\n\nexport interface WorkflowStageReport {\n\tstage: string;\n\t/** What the stage received. */\n\tinput: unknown;\n\t/** The stage's validated answer. */\n\toutput: unknown;\n\t/** How many answers the stage's agent gave, the last one valid. */\n\tattempts: number;\n\t/** Seconds. */\n\tduration: number;\n\t/** Absent when a provider did not report an answer's usage. */\n\ttokens?: Tokens;\n\ttransition: 'go' | 'stop' | 'complete';\n\t/** The stage that ran next, absent after `stop` or `complete`. */\n\tnextStage?: string;\n}\n\nexport interface WorkflowResult<Result> {\n\t/** `stopped` is a deliberate early end, not a failure: a failure rejects the run. */\n\tstatus: 'completed' | 'stopped';\n\tresult: Result;\n\t/** Every stage's usage. Absent when any answer's usage was not reported. */\n\ttokens?: Tokens;\n\t/** Seconds. */\n\tduration: number;\n\trunId?: string;\n\t/** One report per stage run, in order: what each stage received, answered and chose. */\n\tstages: readonly WorkflowStageReport[];\n}\n\nexport interface Workflow<Input, Result> {\n\trun(input: Input, options: RunOptions): Promise<WorkflowResult<Result>>;\n}\n\nexport interface CreateWorkflowOptions<\n\tInputs,\n\tResult,\n\tInitial extends WorkflowStageName<Inputs> = WorkflowStageName<Inputs>,\n> {\n\tinitialStage: Initial;\n\t/** Every stage, under its own name. */\n\tstages: {\n\t\treadonly [Name in WorkflowStageName<Inputs>]: WorkflowStage<Inputs, Result, Name>;\n\t} & Readonly<Record<string, WorkflowStage<Inputs, Result>>>;\n\t/** The default for a stage without its own. Defaults to 3. */\n\tmaxAttempts?: number;\n\t/** How many times a run may `go` to a stage, so a loop of stages ends. Defaults to 100. */\n\tmaxTransitions?: number;\n\tlogger?: ILogger;\n}\n"]}
1
+ {"version":3,"file":"workflow.js","sourceRoot":"","sources":["../../../src/workflow/domain/workflow.ts"],"names":[],"mappings":"AA2DA;;;;;;;GAOG;AACH,MAAM,UAAU,oBAAoB;IAOnC,OAAO,CAAC,IAAI,EAAE,UAAU,EAAE,EAAE,CAAC,CAAC,EAAE,GAAG,UAAU,EAAE,IAAI,EAAE,CAAC,CAAC;AACxD,CAAC","sourcesContent":["import type { Agent, RunOptions } from '#src/agent/domain/agent';\nimport type { Session } from '#src/shared/domain/session';\nimport type { ILogger } from '#src/shared/domain/logger';\nimport type { Tokens } from '#src/shared/domain/tokens';\nimport type { Validator } from '#src/shared/domain/validator';\n\n/** The names of a workflow's stages: the keys of its map of what each stage receives. */\nexport type WorkflowStageName<Inputs> = keyof Inputs & string;\n\n/**\n * What a stage chose once its answer was valid. `go` runs the stage it names, which receives\n * `input` and nothing else; `stop` ends the run early on purpose and `complete` ends it finished,\n * each with the run's result. `Inputs` maps each stage's name to what it receives, so a `go` that\n * names a stage that does not exist, or hands it the wrong input, does not compile.\n */\nexport type WorkflowTransition<Inputs, Result> =\n\t| {\n\t\t\t[Name in WorkflowStageName<Inputs>]: { type: 'go'; stage: Name; input: Inputs[Name] };\n\t }[WorkflowStageName<Inputs>]\n\t| { type: 'stop'; result: Result }\n\t| { type: 'complete'; result: Result };\n\n/**\n * One step of a workflow: how it creates its agent, the prompt that agent gets from the stage's\n * input, how its JSON answer is validated, and the transition the valid answer selects.\n */\nexport interface WorkflowStageDefinition<\n\tInputs,\n\tName extends WorkflowStageName<Inputs>,\n\tOutput,\n\tResult,\n> {\n\t/**\n\t * Creates the stage's agent, with a context of its own, the first time the stage runs in a\n\t * run. When the run comes back to the stage, the same agent goes on with its conversation;\n\t * the next run creates another. It may be async, as `createLLMOrchestrator` is.\n\t */\n\tagent(): Agent | PromiseLike<Agent>;\n\tprompt(input: Inputs[Name]): string | PromiseLike<string>;\n\t/** Receives the agent's answer parsed as JSON. */\n\treadonly validator: Validator<Output>;\n\t/** Receives the validated answer and the stage's own input, to carry forward what the next stage needs. */\n\ttransition(\n\t\toutput: Output,\n\t\tinput: Inputs[Name],\n\t): WorkflowTransition<Inputs, Result> | PromiseLike<WorkflowTransition<Inputs, Result>>;\n\t/** How many answers the stage may give before an invalid one fails the run. */\n\treadonly maxAttempts?: number;\n}\n\n/** A stage bound to its name, as `defineWorkflowStages` returns it. */\nexport interface WorkflowStage<\n\tInputs,\n\tResult,\n\tName extends WorkflowStageName<Inputs> = WorkflowStageName<Inputs>,\n> extends WorkflowStageDefinition<Inputs, Name, unknown, Result> {\n\treadonly name: Name;\n}\n\n/**\n * Binds stage definitions to one workflow's map of stage inputs and its result. The function it\n * returns infers each stage's output from its validator.\n *\n * @example\n * const stage = defineWorkflowStages<{ triage: string; draft: Brief }, string>();\n * const triage = stage('triage', { agent, prompt, validator, transition });\n */\nexport function defineWorkflowStages<Inputs, Result>(): <\n\tName extends WorkflowStageName<Inputs>,\n\tOutput,\n>(\n\tname: Name,\n\tdefinition: WorkflowStageDefinition<Inputs, Name, Output, Result>,\n) => WorkflowStage<Inputs, Result, Name> {\n\treturn (name, definition) => ({ ...definition, name });\n}\n\nexport interface WorkflowStageReport {\n\tstage: string;\n\t/** What the stage received. */\n\tinput: unknown;\n\t/** The stage's validated answer. */\n\toutput: unknown;\n\t/** How many answers the stage's agent gave, the last one valid. */\n\tattempts: number;\n\t/** Seconds. */\n\tduration: number;\n\t/** Absent when a provider did not report an answer's usage. */\n\ttokens?: Tokens;\n\ttransition: 'go' | 'stop' | 'complete';\n\t/** The stage that ran next, absent after `stop` or `complete`. */\n\tnextStage?: string;\n}\n\nexport interface WorkflowResult<Result> {\n\t/** `stopped` is a deliberate early end, not a failure: a failure rejects the run. */\n\tstatus: 'completed' | 'stopped';\n\tresult: Result;\n\t/** Every stage's usage. Absent when any answer's usage was not reported. */\n\ttokens?: Tokens;\n\t/** Seconds. */\n\tduration: number;\n\trunId?: string;\n\t/** The session the run belonged to: the one `RunOptions.session` gave, or a new one. */\n\tsession?: Session;\n\t/** One report per stage run, in order: what each stage received, answered and chose. */\n\tstages: readonly WorkflowStageReport[];\n}\n\nexport interface Workflow<Input, Result> {\n\trun(input: Input, options: RunOptions): Promise<WorkflowResult<Result>>;\n}\n\nexport interface CreateWorkflowOptions<\n\tInputs,\n\tResult,\n\tInitial extends WorkflowStageName<Inputs> = WorkflowStageName<Inputs>,\n> {\n\tinitialStage: Initial;\n\t/** Every stage, under its own name. */\n\tstages: {\n\t\treadonly [Name in WorkflowStageName<Inputs>]: WorkflowStage<Inputs, Result, Name>;\n\t} & Readonly<Record<string, WorkflowStage<Inputs, Result>>>;\n\t/** The default for a stage without its own. Defaults to 3. */\n\tmaxAttempts?: number;\n\t/** How many times a run may `go` to a stage, so a loop of stages ends. Defaults to 100. */\n\tmaxTransitions?: number;\n\tlogger?: ILogger;\n}\n"]}