@turri-dev/cli 0.0.0-stage → 0.1.1

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 (315) hide show
  1. package/.pi/themes/turri-dark.json +80 -0
  2. package/.pi/themes/turri-light.json +82 -0
  3. package/LICENSE +21 -0
  4. package/README.md +272 -2
  5. package/assets/jellyfish/dark-00.png +0 -0
  6. package/assets/jellyfish/dark-01.png +0 -0
  7. package/assets/jellyfish/dark-02.png +0 -0
  8. package/assets/jellyfish/dark-03.png +0 -0
  9. package/assets/jellyfish/dark-04.png +0 -0
  10. package/assets/jellyfish/dark-05.png +0 -0
  11. package/assets/jellyfish/dark-06.png +0 -0
  12. package/assets/jellyfish/dark-07.png +0 -0
  13. package/assets/jellyfish/dark-08.png +0 -0
  14. package/assets/jellyfish/dark-09.png +0 -0
  15. package/assets/jellyfish/dark-10.png +0 -0
  16. package/assets/jellyfish/dark-11.png +0 -0
  17. package/assets/jellyfish/dark-12.png +0 -0
  18. package/assets/jellyfish/dark-13.png +0 -0
  19. package/assets/jellyfish/dark-14.png +0 -0
  20. package/assets/jellyfish/fallback.txt +252 -0
  21. package/assets/jellyfish/light-00.png +0 -0
  22. package/assets/jellyfish/light-01.png +0 -0
  23. package/assets/jellyfish/light-02.png +0 -0
  24. package/assets/jellyfish/light-03.png +0 -0
  25. package/assets/jellyfish/light-04.png +0 -0
  26. package/assets/jellyfish/light-05.png +0 -0
  27. package/assets/jellyfish/light-06.png +0 -0
  28. package/assets/jellyfish/light-07.png +0 -0
  29. package/assets/jellyfish/light-08.png +0 -0
  30. package/assets/jellyfish/light-09.png +0 -0
  31. package/assets/jellyfish/light-10.png +0 -0
  32. package/assets/jellyfish/light-11.png +0 -0
  33. package/assets/jellyfish/light-12.png +0 -0
  34. package/assets/jellyfish/light-13.png +0 -0
  35. package/assets/jellyfish/light-14.png +0 -0
  36. package/bin/turri.mjs +29 -0
  37. package/dist/ask.js +149 -0
  38. package/dist/brand.js +50 -0
  39. package/dist/btw.js +174 -0
  40. package/dist/chat-models.js +37 -0
  41. package/dist/chat-skills.js +18 -0
  42. package/dist/cli.js +178 -0
  43. package/dist/config-command.js +197 -0
  44. package/dist/config-edit.js +367 -0
  45. package/dist/config.js +489 -0
  46. package/dist/conform-cli.js +29 -0
  47. package/dist/cultivate-cli.js +110 -0
  48. package/dist/drift-cli.js +25 -0
  49. package/dist/environment.js +70 -0
  50. package/dist/extension.js +2372 -0
  51. package/dist/genome.js +301 -0
  52. package/dist/graph.js +246 -0
  53. package/dist/identity.js +35 -0
  54. package/dist/init-config.js +511 -0
  55. package/dist/judge.js +62 -0
  56. package/dist/lineage-cli.js +13 -0
  57. package/dist/loop-cli.js +132 -0
  58. package/dist/loop-tools.js +225 -0
  59. package/dist/mature-tools.js +193 -0
  60. package/dist/model-check.js +117 -0
  61. package/dist/module-report.js +9 -0
  62. package/dist/pi-launch.js +31 -0
  63. package/dist/pi.js +813 -0
  64. package/dist/plain/check.js +76 -0
  65. package/dist/plain/concepts.js +213 -0
  66. package/dist/plain/errors.js +43 -0
  67. package/dist/plain/files.js +57 -0
  68. package/dist/plain/highlight.js +195 -0
  69. package/dist/plain/index.js +15 -0
  70. package/dist/plain/liquid.js +129 -0
  71. package/dist/plain/markdown.js +208 -0
  72. package/dist/plain/parser.js +752 -0
  73. package/dist/plain/plain.tmLanguage.json +124 -0
  74. package/dist/plain/spec.js +291 -0
  75. package/dist/plain/stdlib.js +44 -0
  76. package/dist/question-dialog.js +125 -0
  77. package/dist/questioning.js +109 -0
  78. package/dist/quiet.js +125 -0
  79. package/dist/regenerate/activity.js +187 -0
  80. package/dist/regenerate/agent-env.js +190 -0
  81. package/dist/regenerate/agent-shell.js +114 -0
  82. package/dist/regenerate/arbiter.js +133 -0
  83. package/dist/regenerate/audit.js +203 -0
  84. package/dist/regenerate/cases.js +83 -0
  85. package/dist/regenerate/change-view.js +102 -0
  86. package/dist/regenerate/choose-conformance.js +117 -0
  87. package/dist/regenerate/chunk-plan.js +625 -0
  88. package/dist/regenerate/codebase.js +75 -0
  89. package/dist/regenerate/command-check.js +312 -0
  90. package/dist/regenerate/compaction.js +20 -0
  91. package/dist/regenerate/confine.js +310 -0
  92. package/dist/regenerate/conform.js +133 -0
  93. package/dist/regenerate/conformance-agents.js +955 -0
  94. package/dist/regenerate/conformance.js +936 -0
  95. package/dist/regenerate/conversation-writes.js +100 -0
  96. package/dist/regenerate/credentials.js +88 -0
  97. package/dist/regenerate/debug-log.js +228 -0
  98. package/dist/regenerate/debugger.js +90 -0
  99. package/dist/regenerate/delivery.js +19 -0
  100. package/dist/regenerate/diagnose-job.js +40 -0
  101. package/dist/regenerate/diagnose.js +81 -0
  102. package/dist/regenerate/diff-view.js +240 -0
  103. package/dist/regenerate/drift.js +194 -0
  104. package/dist/regenerate/engine.js +118 -0
  105. package/dist/regenerate/environment-error.js +220 -0
  106. package/dist/regenerate/job-log.js +102 -0
  107. package/dist/regenerate/job-panel.js +336 -0
  108. package/dist/regenerate/jobs-tree.js +300 -0
  109. package/dist/regenerate/jobs.js +427 -0
  110. package/dist/regenerate/lanes.js +32 -0
  111. package/dist/regenerate/lineage.js +384 -0
  112. package/dist/regenerate/loop.js +1435 -0
  113. package/dist/regenerate/masking.js +95 -0
  114. package/dist/regenerate/notes.js +397 -0
  115. package/dist/regenerate/os-sandbox.js +243 -0
  116. package/dist/regenerate/paths.js +165 -0
  117. package/dist/regenerate/plain-palette.js +83 -0
  118. package/dist/regenerate/prompt.js +704 -0
  119. package/dist/regenerate/records.js +294 -0
  120. package/dist/regenerate/regenerate.js +598 -0
  121. package/dist/regenerate/regression.js +181 -0
  122. package/dist/regenerate/release.js +262 -0
  123. package/dist/regenerate/requires.js +241 -0
  124. package/dist/regenerate/resources.js +164 -0
  125. package/dist/regenerate/self-check.js +111 -0
  126. package/dist/regenerate/services.js +155 -0
  127. package/dist/regenerate/shell.js +319 -0
  128. package/dist/regenerate/side-panel.js +278 -0
  129. package/dist/regenerate/spec-map-view.js +574 -0
  130. package/dist/regenerate/spec-map.js +561 -0
  131. package/dist/regenerate/spec-steps.js +527 -0
  132. package/dist/regenerate/state.js +10 -0
  133. package/dist/regenerate/steer.js +91 -0
  134. package/dist/regenerate/steering.js +119 -0
  135. package/dist/regenerate/stop-report.js +161 -0
  136. package/dist/regenerate/submit-work.js +71 -0
  137. package/dist/regenerate/test-kinds.js +124 -0
  138. package/dist/regenerate/test-scripts.js +695 -0
  139. package/dist/regenerate/testing.js +406 -0
  140. package/dist/regenerate/transcript-view.js +1 -0
  141. package/dist/regenerate/unit-gate.js +20 -0
  142. package/dist/regenerate/unit-review.js +347 -0
  143. package/dist/regenerate/web.js +256 -0
  144. package/dist/regenerate/workspace.js +99 -0
  145. package/dist/regenerate-cli.js +68 -0
  146. package/dist/release-agent.js +114 -0
  147. package/dist/release-cli.js +131 -0
  148. package/dist/release-tools.js +148 -0
  149. package/dist/review/live.js +165 -0
  150. package/dist/rules.js +47 -0
  151. package/dist/self-invocation.js +110 -0
  152. package/dist/setup.js +245 -0
  153. package/dist/skill-progress.js +78 -0
  154. package/dist/skill.js +149 -0
  155. package/dist/skills.js +74 -0
  156. package/dist/spec-diff-cli.js +37 -0
  157. package/dist/spec-tools.js +155 -0
  158. package/dist/stack.js +158 -0
  159. package/dist/stacks.js +97 -0
  160. package/dist/stdlib.js +100 -0
  161. package/dist/terminal-images.js +46 -0
  162. package/dist/tools.js +242 -0
  163. package/dist/validate-cli.js +52 -0
  164. package/docs/README.md +58 -0
  165. package/docs/authoring-plain.md +245 -0
  166. package/docs/config.md +353 -0
  167. package/docs/deploy.md +109 -0
  168. package/docs/development.md +75 -0
  169. package/docs/headless.md +148 -0
  170. package/docs/images/how-turri-works.svg +50 -0
  171. package/docs/images/turri-banner.png +0 -0
  172. package/docs/installation.md +118 -0
  173. package/docs/parser.md +108 -0
  174. package/docs/regenerating.md +1116 -0
  175. package/docs/sandbox.md +233 -0
  176. package/docs/skills-and-gates.md +254 -0
  177. package/docs/theming.md +76 -0
  178. package/docs/using.md +454 -0
  179. package/genome/rules/bullet-continuation.md +69 -0
  180. package/genome/rules/concise-specs-examples.md +431 -0
  181. package/genome/rules/concise-specs-integration-examples.md +270 -0
  182. package/genome/rules/concise-specs.md +80 -0
  183. package/genome/rules/definitions.md +107 -0
  184. package/genome/rules/examples/integration-embedded-testing/run_conformance_tests_java.sh +163 -0
  185. package/genome/rules/examples/integration-embedded-testing/run_unittests_java.sh +51 -0
  186. package/genome/rules/exported-concepts.md +39 -0
  187. package/genome/rules/func-specs.md +129 -0
  188. package/genome/rules/impl-reqs.md +68 -0
  189. package/genome/rules/import-modules.md +48 -0
  190. package/genome/rules/integration-embedded-testing.md +276 -0
  191. package/genome/rules/integration-embedded.md +171 -0
  192. package/genome/rules/integration-standalone.md +135 -0
  193. package/genome/rules/integrations.md +137 -0
  194. package/genome/rules/linked-resources.md +69 -0
  195. package/genome/rules/module-structure.md +105 -0
  196. package/genome/rules/project-model.md +88 -0
  197. package/genome/rules/required-concepts.md +44 -0
  198. package/genome/rules/requires-modules.md +80 -0
  199. package/genome/rules/stdlib.md +73 -0
  200. package/genome/rules/test-reqs.md +57 -0
  201. package/genome/skills/add-acceptance-test/SKILL.md +100 -0
  202. package/genome/skills/add-concept/SKILL.md +71 -0
  203. package/genome/skills/add-functional-spec/SKILL.md +84 -0
  204. package/genome/skills/add-functional-specs/SKILL.md +118 -0
  205. package/genome/skills/add-implementation-requirement/SKILL.md +86 -0
  206. package/genome/skills/add-resource/SKILL.md +105 -0
  207. package/genome/skills/add-template/SKILL.md +64 -0
  208. package/genome/skills/add-test-requirement/SKILL.md +73 -0
  209. package/genome/skills/analyze-2-func-specs/SKILL.md +101 -0
  210. package/genome/skills/analyze-func-specs/SKILL.md +123 -0
  211. package/genome/skills/analyze-if-func-spec-too-complex/SKILL.md +211 -0
  212. package/genome/skills/break-down-func-spec/SKILL.md +205 -0
  213. package/genome/skills/brood/SKILL.md +229 -0
  214. package/genome/skills/brood/references/intent.md +35 -0
  215. package/genome/skills/brood/references/product.md +101 -0
  216. package/genome/skills/brood/references/stack.md +61 -0
  217. package/genome/skills/brood/references/testing.md +56 -0
  218. package/genome/skills/bud/SKILL.md +148 -0
  219. package/genome/skills/bud/references/checklist.md +44 -0
  220. package/genome/skills/bud/references/intent.md +30 -0
  221. package/genome/skills/check-plain-env/SKILL.md +285 -0
  222. package/genome/skills/consolidate-concepts/SKILL.md +192 -0
  223. package/genome/skills/create-import-module/SKILL.md +104 -0
  224. package/genome/skills/create-requires-module/SKILL.md +109 -0
  225. package/genome/skills/debug-specs/SKILL.md +188 -0
  226. package/genome/skills/implement-conformance-testing-script/SKILL.md +238 -0
  227. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_cypress.ps1 +337 -0
  228. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_golang.ps1 +111 -0
  229. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_java.sh +136 -0
  230. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_python.ps1 +103 -0
  231. package/genome/skills/implement-conformance-testing-script/assets/run_conformance_tests_python.sh +150 -0
  232. package/genome/skills/implement-unit-testing-script/SKILL.md +160 -0
  233. package/genome/skills/implement-unit-testing-script/assets/run_unittests_flutter.ps1 +87 -0
  234. package/genome/skills/implement-unit-testing-script/assets/run_unittests_golang.ps1 +73 -0
  235. package/genome/skills/implement-unit-testing-script/assets/run_unittests_java.sh +72 -0
  236. package/genome/skills/implement-unit-testing-script/assets/run_unittests_python.ps1 +81 -0
  237. package/genome/skills/implement-unit-testing-script/assets/run_unittests_python.sh +98 -0
  238. package/genome/skills/implement-unit-testing-script/assets/run_unittests_react.ps1 +87 -0
  239. package/genome/skills/init-plain-project/SKILL.md +117 -0
  240. package/genome/skills/init-plain-project/references/checklist.md +30 -0
  241. package/genome/skills/mature/SKILL.md +126 -0
  242. package/genome/skills/refactor-module/SKILL.md +196 -0
  243. package/genome/skills/resolve-section-ownership/SKILL.md +64 -0
  244. package/genome/skills/resolve-spec-conflict/SKILL.md +96 -0
  245. package/genome/skills/spawn/SKILL.md +163 -0
  246. package/package.json +68 -4
  247. package/stdlib/backend/common.plain +25 -0
  248. package/stdlib/backend/conformance/common.plain +12 -0
  249. package/stdlib/backend/conformance/go-gin.plain +9 -0
  250. package/stdlib/backend/conformance/go-nethttp.plain +9 -0
  251. package/stdlib/backend/conformance/java-springboot.plain +9 -0
  252. package/stdlib/backend/conformance/python-django.plain +9 -0
  253. package/stdlib/backend/conformance/python-fastapi.plain +9 -0
  254. package/stdlib/backend/conformance/python-flask.plain +9 -0
  255. package/stdlib/backend/conformance/rust-axum.plain +9 -0
  256. package/stdlib/backend/conformance/typescript-express.plain +9 -0
  257. package/stdlib/backend/conformance/typescript-fastify.plain +9 -0
  258. package/stdlib/backend/conformance/typescript-nestjs.plain +9 -0
  259. package/stdlib/backend/go-gin.plain +18 -0
  260. package/stdlib/backend/go-nethttp.plain +18 -0
  261. package/stdlib/backend/java-springboot.plain +19 -0
  262. package/stdlib/backend/python-django.plain +19 -0
  263. package/stdlib/backend/python-fastapi.plain +23 -0
  264. package/stdlib/backend/python-flask.plain +23 -0
  265. package/stdlib/backend/rust-axum.plain +17 -0
  266. package/stdlib/backend/typescript-express.plain +19 -0
  267. package/stdlib/backend/typescript-fastify.plain +19 -0
  268. package/stdlib/backend/typescript-nestjs.plain +19 -0
  269. package/stdlib/cli/common.plain +17 -0
  270. package/stdlib/cli/conformance/common.plain +11 -0
  271. package/stdlib/cli/conformance/go-cobra.plain +9 -0
  272. package/stdlib/cli/conformance/go-flag.plain +9 -0
  273. package/stdlib/cli/conformance/java-picocli.plain +9 -0
  274. package/stdlib/cli/conformance/python-argparse.plain +9 -0
  275. package/stdlib/cli/conformance/python-click.plain +9 -0
  276. package/stdlib/cli/conformance/python-typer.plain +9 -0
  277. package/stdlib/cli/conformance/rust-clap.plain +9 -0
  278. package/stdlib/cli/conformance/typescript-commander.plain +9 -0
  279. package/stdlib/cli/go-cobra.plain +14 -0
  280. package/stdlib/cli/go-flag.plain +14 -0
  281. package/stdlib/cli/java-picocli.plain +15 -0
  282. package/stdlib/cli/python-argparse.plain +18 -0
  283. package/stdlib/cli/python-click.plain +16 -0
  284. package/stdlib/cli/python-typer.plain +16 -0
  285. package/stdlib/cli/rust-clap.plain +14 -0
  286. package/stdlib/cli/typescript-commander.plain +17 -0
  287. package/stdlib/frontend/angular.plain +17 -0
  288. package/stdlib/frontend/common.plain +17 -0
  289. package/stdlib/frontend/conformance/angular.plain +5 -0
  290. package/stdlib/frontend/conformance/common.plain +11 -0
  291. package/stdlib/frontend/conformance/react-vite.plain +5 -0
  292. package/stdlib/frontend/conformance/sveltekit-spa.plain +5 -0
  293. package/stdlib/frontend/conformance/vue-vite.plain +5 -0
  294. package/stdlib/frontend/react-vite.plain +16 -0
  295. package/stdlib/frontend/sveltekit-spa.plain +16 -0
  296. package/stdlib/frontend/vue-vite.plain +16 -0
  297. package/stdlib/lang/go.plain +13 -0
  298. package/stdlib/lang/java.plain +12 -0
  299. package/stdlib/lang/python.plain +18 -0
  300. package/stdlib/lang/rust.plain +12 -0
  301. package/stdlib/lang/typescript.plain +13 -0
  302. package/stdlib/library/common.plain +15 -0
  303. package/stdlib/library/conformance/common.plain +9 -0
  304. package/stdlib/library/conformance/go.plain +9 -0
  305. package/stdlib/library/conformance/java-gradle.plain +9 -0
  306. package/stdlib/library/conformance/java-maven.plain +9 -0
  307. package/stdlib/library/conformance/python.plain +9 -0
  308. package/stdlib/library/conformance/rust.plain +9 -0
  309. package/stdlib/library/conformance/typescript.plain +9 -0
  310. package/stdlib/library/go.plain +12 -0
  311. package/stdlib/library/java-gradle.plain +13 -0
  312. package/stdlib/library/java-maven.plain +13 -0
  313. package/stdlib/library/python.plain +13 -0
  314. package/stdlib/library/rust.plain +12 -0
  315. package/stdlib/library/typescript.plain +15 -0
package/dist/pi.js ADDED
@@ -0,0 +1,813 @@
1
+ import { basename, dirname, join, resolve, sep } from "node:path";
2
+ import { fileURLToPath } from "node:url";
3
+ import { createAgentSession, DefaultResourceLoader, getAgentDir, ModelRegistry, ModelRuntime, SessionManager, } from "@earendil-works/pi-coding-agent";
4
+ import { loadRules, renderRulesBlock } from "./rules.js";
5
+ import { inheritModel, stackOf, readProjectConfig, ROLE_THINKING, roleModel, roleTurnLimitMs, splitModelSpec } from "./config.js";
6
+ import { maskToolResults } from "./regenerate/masking.js";
7
+ import { checkAgainstScript, ENVIRONMENT_ERROR_TOOL, EnvironmentError, environmentErrorTool, sandboxGapOf } from "./regenerate/environment-error.js";
8
+ import { MODULES_DIR, projectRootOf, sessionsDir } from "./regenerate/paths.js";
9
+ import { currentStopSignal, describeToolCall, describeToolResult, JobStoppedError, shownName, reportActivity, reportNow, reportAgent, reportTranscript, reportUsage, } from "./regenerate/activity.js";
10
+ import { debugLogOf, startDebugLog } from "./regenerate/debug-log.js";
11
+ let thinkingSource;
12
+ /**
13
+ * Where sub-agents take their thinking level from: the conversation that started them.
14
+ *
15
+ * The model is inherited explicitly, by name, all the way down. The thinking level is just
16
+ * as much a choice the person made - and left alone pi falls back to its own default, so a
17
+ * conversation turned down for speed would hand every render agent a slower one. Read at
18
+ * the moment each session starts, so a change mid-conversation applies to the next agent.
19
+ */
20
+ export function inheritThinkingLevel(source) {
21
+ thinkingSource = source;
22
+ }
23
+ /**
24
+ * How hard an agent thinks: the level the config or the model it is given names; else its role's (ROLE_THINKING) - the
25
+ * work thinks little, the judgement more; else the conversation's, for an agent with no role.
26
+ */
27
+ export function agentThinking(named, role, conversation) {
28
+ return named ?? (role !== undefined ? ROLE_THINKING[role] : undefined) ?? conversation();
29
+ }
30
+ function inheritedThinkingLevel() {
31
+ try {
32
+ return thinkingSource?.();
33
+ }
34
+ catch {
35
+ // The conversation it came from is gone - a reload, a resumed session. pi's default is
36
+ // a worse guess than the conversation's level, but a far better one than a failed build.
37
+ return undefined;
38
+ }
39
+ }
40
+ let providerSource;
41
+ /**
42
+ * Where sub-agents take the providers pi's extensions registered from: the conversation that started them.
43
+ *
44
+ * A sub-agent is started without extensions (see subAgentResources), so its models are only what pi's own
45
+ * configuration has - models.json and auth.json. A provider an extension registers - a company's own LLM
46
+ * gateway, say - is in the conversation's model registry and nowhere else: the conversation could use its
47
+ * models and a render or skill agent told to use them could not find them. So every sub-agent's model
48
+ * runtime is given the providers the conversation's registry holds when the agent starts - the provider,
49
+ * not the extension that registered it.
50
+ */
51
+ export function inheritProviders(source) {
52
+ providerSource = source;
53
+ }
54
+ /** A model runtime for a sub-agent: pi's configuration, with the providers the conversation's extensions registered. */
55
+ async function subAgentModelRuntime() {
56
+ const agentDir = getAgentDir();
57
+ const runtime = await ModelRuntime.create({ authPath: join(agentDir, "auth.json"), modelsPath: join(agentDir, "models.json") });
58
+ let source;
59
+ try {
60
+ source = providerSource?.();
61
+ }
62
+ catch {
63
+ // The conversation it came from is gone: pi's own configuration is what there is.
64
+ }
65
+ if (source === undefined)
66
+ return runtime;
67
+ const registry = new ModelRegistry(runtime);
68
+ for (const id of source.getRegisteredProviderIds()) {
69
+ const native = source.getRegisteredNativeProvider(id);
70
+ const config = source.getRegisteredProviderConfig(id);
71
+ try {
72
+ if (native !== undefined)
73
+ registry.registerProvider(native);
74
+ else if (config !== undefined)
75
+ registry.registerProvider(id, config);
76
+ }
77
+ catch {
78
+ // One provider that will not register leaves the others, and pi's own, usable.
79
+ }
80
+ }
81
+ // Its models are listed as the registrations land; the agent's model is looked up right after.
82
+ await runtime.refresh({ allowNetwork: false });
83
+ return runtime;
84
+ }
85
+ const RULES_DIR = join(dirname(dirname(fileURLToPath(import.meta.url))), "genome", "rules");
86
+ /**
87
+ * What a sub-agent session is made of: pi's resources without any extensions.
88
+ *
89
+ * Left to itself, pi builds every session from the user's configuration - including every
90
+ * installed extension. When turri is installed globally, that means each render agent,
91
+ * judge and skill executor loads a whole second copy of turri inside itself: its
92
+ * tools, its hooks, its job registry, all bound to a session that is thrown away minutes
93
+ * later. That copy then outlives its session in module state and breaks the next one. A
94
+ * sub-agent is given exactly what it needs instead; for agents that work on specs, that
95
+ * includes the ***plain rules, which otherwise only reached them through that stray copy.
96
+ *
97
+ * Nor any context file. pi puts every AGENTS.md / CLAUDE.md from the agent's folder up - the
98
+ * project's, the user's ~/.pi/agent one - into its system prompt; a rendering agent works in
99
+ * plain_modules/<module>/code, so it would be given whatever the project says about itself. An
100
+ * agent turri starts sees only what turri gives it. The conversation still loads
101
+ * them, as pi does.
102
+ *
103
+ * Nor any of pi's skills. pi lists every skill it finds - the user's, the project's - in the system prompt, for the
104
+ * agent to load when one fits; an agent turri starts has one job, and turri runs its gated skills itself,
105
+ * giving each step to an agent as its task. The conversation keeps them.
106
+ */
107
+ export async function subAgentResources(cwd, withRules, system) {
108
+ // Less any the project leaves out (rules-off).
109
+ const rules = withRules ? renderRulesBlock(loadRules(RULES_DIR, readProjectConfig(projectRootOf(cwd)).rulesOff ?? [])) : "";
110
+ const extra = [rules, system ?? ""].filter((part) => part !== "");
111
+ const loader = new DefaultResourceLoader({
112
+ cwd,
113
+ agentDir: getAgentDir(),
114
+ noExtensions: true,
115
+ noContextFiles: true,
116
+ noSkills: true,
117
+ appendSystemPrompt: extra.length > 0 ? extra : undefined,
118
+ });
119
+ await loader.reload();
120
+ return loader;
121
+ }
122
+ function seconds(ms) {
123
+ const s = Math.round(ms / 1000);
124
+ return s < 60 ? `${s}s` : `${Math.floor(s / 60)}m ${s % 60}s`;
125
+ }
126
+ const CALL_PROGRESS_MS = 500;
127
+ /** How much of a tool call's arguments and result the transcript keeps: enough to follow, not the whole file. */
128
+ const MAX_TOOL_ARGS = 600;
129
+ const MAX_TOOL_RESULT = 2000;
130
+ function clipText(text, max) {
131
+ return text.length <= max ? text : `${text.slice(0, max)}… [${text.length - max} more characters]`;
132
+ }
133
+ function resultText(result) {
134
+ const content = result?.content;
135
+ if (!Array.isArray(content))
136
+ return "";
137
+ return content
138
+ .filter((part) => part.type === "text" && typeof part.text === "string")
139
+ .map((part) => part.text)
140
+ .join("\n");
141
+ }
142
+ /** Raised when the provider itself failed, rather than the agent producing a poor answer. */
143
+ export class ProviderError extends Error {
144
+ constructor(message) {
145
+ super(message);
146
+ this.name = "ProviderError";
147
+ }
148
+ }
149
+ /**
150
+ * Run one turn and return the assistant's text, streaming deltas to `onDelta`.
151
+ *
152
+ * A provider failure - no credits, rate limit, bad key - arrives as an assistant message
153
+ * with `stopReason: "error"` and no content. Left alone that looks exactly like an agent
154
+ * that chose to say nothing, and any retry loop above will keep going as if the work were
155
+ * merely unfinished. It is raised instead.
156
+ */
157
+ /** For each agent with a shell: where it works, and what it reported the environment lacks, if it did. */
158
+ const environmentReports = new WeakMap();
159
+ /** The agent's turn ended on report_environment_error: the pulse stops with what it said, as it said it. */
160
+ function throwIfEnvironmentReported(session) {
161
+ const entry = environmentReports.get(session);
162
+ const report = entry?.reported.report;
163
+ if (entry === undefined || report === undefined)
164
+ return;
165
+ entry.reported.report = undefined;
166
+ const parts = resolve(entry.cwd).split(sep);
167
+ const index = parts.lastIndexOf(MODULES_DIR);
168
+ const moduleName = index >= 0 && parts.length > index + 1 ? parts[index + 1] : basename(resolve(entry.cwd));
169
+ // A file it names that is on this machine: not missing - turri's sandbox hid it, and the fix is a line of config.
170
+ const sandbox = sandboxGapOf([report.missing, report.neededBy, report.shownBy ?? ""].join("\n"), { project: projectRootOf(entry.cwd) });
171
+ throw new EnvironmentError({ moduleName, missing: report.missing, neededBy: report.neededBy, ...(report.shownBy !== undefined ? { shownBy: report.shownBy } : {}), source: "agent", ...(sandbox !== undefined ? { sandbox } : {}) });
172
+ }
173
+ /** The tools a session's turn may use, while one is restricted (ask's `onlyTools`). */
174
+ const turnTools = new WeakMap();
175
+ /** How many calls a restricted turn may have refused before it is stopped. */
176
+ export const REFUSED_BEFORE_STOP = 3;
177
+ /**
178
+ * `run`, with every tool of the session but `names` refused (guardRepeats refuses them) until it ends. The tools
179
+ * themselves are left as they are, so the tools the model is sent - and its prompt cache - stay the same.
180
+ */
181
+ export async function withOnlyTools(session, names, run) {
182
+ turnTools.set(session, new Set(names));
183
+ try {
184
+ return await run();
185
+ }
186
+ finally {
187
+ turnTools.delete(session);
188
+ }
189
+ }
190
+ export async function ask(session, text, onDelta, turn) {
191
+ if (turn?.onlyTools === undefined)
192
+ return askUnrestricted(session, text, onDelta);
193
+ return withOnlyTools(session, turn.onlyTools, () => askUnrestricted(session, text, onDelta));
194
+ }
195
+ async function askUnrestricted(session, text, onDelta) {
196
+ // Between turns, not during one: the work so far, summed up, once it has got larger than the threshold.
197
+ // The summary keeps no images: they go with the next message again.
198
+ if (await compactIfDue(session)) {
199
+ const images = sessionImages.get(session);
200
+ if (images !== undefined)
201
+ images.sent = false;
202
+ }
203
+ // A provider that is overloaded or unreachable for a moment is waited out, longer than pi's own
204
+ // retries do, rather than failing the whole run; the agent carries on where it was.
205
+ let prompt = text;
206
+ for (let attempt = 0;; attempt += 1) {
207
+ try {
208
+ const reply = await askOnce(session, prompt, onDelta);
209
+ throwIfEnvironmentReported(session);
210
+ return reply;
211
+ }
212
+ catch (error) {
213
+ if (!(error instanceof ProviderError) || !TRANSIENT_PROVIDER_ERROR.test(error.message) || attempt >= PROVIDER_RETRIES)
214
+ throw error;
215
+ const wait = PROVIDER_RETRY_DELAY_MS * 2 ** attempt;
216
+ reportActivity(` the model provider is failing for now - trying again in ${Math.round(wait / 1000)}s (${attempt + 1} of ${PROVIDER_RETRIES})`);
217
+ debugLogOf(session)?.note(`provider failing - trying again in ${Math.round(wait / 1000)}s: ${error.message.slice(0, 200)}`);
218
+ await waitOrStop(wait);
219
+ prompt = "Carry on where you were: the model provider failed part-way through your last turn.";
220
+ }
221
+ }
222
+ }
223
+ /** How many times, after pi's own retries, a turn whose provider failed for a moment is tried again - and the first wait, doubled each time. */
224
+ export const PROVIDER_RETRIES = 3;
225
+ export const PROVIDER_RETRY_DELAY_MS = Number(process.env.TURRI_PROVIDER_RETRY_DELAY ?? 15) * 1000;
226
+ /** A provider error that passes: overloaded, rate limited, unreachable, timed out. */
227
+ const TRANSIENT_PROVIDER_ERROR = /\b(429|500|502|503|504|529)\b|overloaded|high demand|rate.?limit|too many requests|unavailable|fetch failed|network|connection|timed? ?out|ECONNRESET|socket|terminated/i;
228
+ /** Wait, unless the job is stopped first. */
229
+ async function waitOrStop(ms) {
230
+ const stop = currentStopSignal();
231
+ if (stop?.aborted)
232
+ throw new JobStoppedError();
233
+ await new Promise((resolve) => {
234
+ const timer = setTimeout(resolve, ms);
235
+ stop?.addEventListener("abort", () => (clearTimeout(timer), resolve()), { once: true });
236
+ });
237
+ if (stop?.aborted)
238
+ throw new JobStoppedError();
239
+ }
240
+ async function askOnce(session, text, onDelta) {
241
+ let out = "";
242
+ let providerError;
243
+ // Inside a background job, what the agent is doing is what the person watches - and most
244
+ // of the time that is the model, not a tool. A write takes a millisecond; producing the
245
+ // file it writes takes as long as the model needs to type it out. So the time from the
246
+ // model starting a step to the tool running is shown against the tool, and while it is
247
+ // happening the panel says which part it is at.
248
+ let stepStarted = Date.now();
249
+ let callChars = 0;
250
+ let lastCallReport = 0;
251
+ // Calls to one tool failing one after another - arguments it refuses, or errors - with nothing going through.
252
+ let failing = 0;
253
+ let failingTool;
254
+ let stoppedFailing = false;
255
+ reportNow("waiting for the model");
256
+ // Every entry says which agent it is from, and what it runs on - for reading one agent at a time.
257
+ const agent = agentOf(session);
258
+ const transcript = (entry) => reportTranscript(agent !== undefined ? { ...entry, agent } : entry);
259
+ // Its task is in its instructions, not in the conversation: the first turn shows it, for reading back.
260
+ const task = pendingTask.get(session);
261
+ pendingTask.delete(session);
262
+ transcript({ kind: "prompt", text: task !== undefined ? `${task}\n\n${text}` : text });
263
+ if (agent !== undefined)
264
+ reportAgent({ label: agent.label, ...(agent.model !== undefined ? { model: agent.model } : {}), turns: 1 });
265
+ const unsubscribe = session.subscribe((event) => {
266
+ if (event.type === "message_update") {
267
+ const update = event.assistantMessageEvent;
268
+ if (update.type === "text_delta") {
269
+ out += update.delta;
270
+ onDelta?.(update.delta);
271
+ transcript({ kind: "text", text: update.delta });
272
+ }
273
+ else if (update.type === "thinking_delta") {
274
+ transcript({ kind: "thinking", text: update.delta });
275
+ }
276
+ else if (update.type === "thinking_start") {
277
+ reportNow("thinking");
278
+ }
279
+ else if (update.type === "text_start") {
280
+ reportNow("writing");
281
+ }
282
+ else if (update.type === "toolcall_start") {
283
+ callChars = 0;
284
+ reportNow("preparing a tool call");
285
+ }
286
+ else if (update.type === "toolcall_delta") {
287
+ callChars += update.delta.length;
288
+ const now = Date.now();
289
+ if (now - lastCallReport >= CALL_PROGRESS_MS) {
290
+ lastCallReport = now;
291
+ const block = update.partial.content[update.contentIndex];
292
+ const size = callChars >= 1000 ? `${(callChars / 1000).toFixed(1)}k` : `${callChars}`;
293
+ reportNow(`writing ${block?.name !== undefined ? shownName(block.name) : "a tool"} call · ${size} chars`, { keepSince: true });
294
+ }
295
+ }
296
+ }
297
+ if (event.type === "tool_execution_start") {
298
+ const args = JSON.stringify(event.args ?? {});
299
+ transcript({ kind: "tool", text: `${shownName(event.toolName)} ${clipText(args, MAX_TOOL_ARGS)}`, full: `${shownName(event.toolName)} ${args}` });
300
+ const took = Date.now() - stepStarted;
301
+ // Which agent made the call, by its role: several work in one pulse.
302
+ const role = agentRoles.get(session);
303
+ const line = `${role !== undefined ? `(${role}) ` : ""}${describeToolCall(event.toolName, event.args)}`;
304
+ reportActivity(took >= 1000 ? `${line} (model ${seconds(took)})` : line);
305
+ reportNow(`running ${shownName(event.toolName)}`);
306
+ }
307
+ if (event.type === "tool_execution_end") {
308
+ failing = event.isError ? (event.toolName === failingTool ? failing + 1 : 1) : 0;
309
+ failingTool = event.isError ? event.toolName : undefined;
310
+ if (failing >= FAILURES_BEFORE_STOP && !stoppedFailing) {
311
+ // A call its tool refuses never reaches the tool, so no guard on the tools sees it: an agent can
312
+ // make the same bad call, worded a little differently, thousands of times. Its turn ends here, and
313
+ // the harness goes on from there, as after any turn.
314
+ stoppedFailing = true;
315
+ reportActivity(` stopped the agent's turn: ${failing} ${shownName(event.toolName)} calls failed in a row`);
316
+ debugLogOf(session)?.note(`turn stopped: ${failing} ${event.toolName} calls failed in a row`);
317
+ void session.abort();
318
+ }
319
+ const output = resultText(event.result);
320
+ transcript({ kind: "result", text: clipText(output, MAX_TOOL_RESULT), error: event.isError, full: output });
321
+ const line = describeToolResult(event.toolName, event.result, event.isError);
322
+ if (line !== undefined)
323
+ reportActivity(line);
324
+ stepStarted = Date.now();
325
+ reportNow("waiting for the model");
326
+ }
327
+ if (event.type === "message_end") {
328
+ const message = event.message;
329
+ if (message.role === "assistant" && message.usage !== undefined) {
330
+ const { input, output, cacheRead, cacheWrite, cost } = message.usage;
331
+ const spent = { input, output, cacheRead, cacheWrite, cost: cost?.total ?? 0 };
332
+ reportUsage(spent);
333
+ if (agent !== undefined)
334
+ reportAgent({ label: agent.label, usage: spent });
335
+ }
336
+ // A provider error counts only if the turn ends on it: a later message that ends well - pi's own retry, the
337
+ // stream picked up again - means it recovered, and the turn's work stands.
338
+ if (message.role === "assistant" && message.stopReason === "error") {
339
+ providerError = message.errorMessage ?? "the provider returned an error";
340
+ }
341
+ else if (message.role === "assistant") {
342
+ providerError = undefined;
343
+ }
344
+ }
345
+ if (event.type === "auto_retry_end" && event.success)
346
+ providerError = undefined;
347
+ });
348
+ // A job asked to stop: abort the turn in progress, and do not start another. Every
349
+ // agent a job runs goes through here, so this is where a stop reaches all of them.
350
+ const stop = currentStopSignal();
351
+ if (stop?.aborted) {
352
+ unsubscribe();
353
+ reportNow(undefined);
354
+ throw new JobStoppedError();
355
+ }
356
+ const onStop = () => void session.abort();
357
+ stop?.addEventListener("abort", onStop, { once: true });
358
+ // For the agent's log: the whole prompt and how the turn ended; the session streams the rest.
359
+ const debug = debugLogOf(session);
360
+ const turnStarted = Date.now();
361
+ // A timed turn is ended when the time is up. The agent is not told: a turn cut short is taken up again by the
362
+ // harness, and a clock in the prompt only hurries the work. It ends at the next request to the model, not in the
363
+ // middle of one: a request aborted in flight is billed whole and uncached, and its answer thrown away. What is running
364
+ // then - the model answering, a tool - finishes; past the grace on top of the limit, it is ended where it is.
365
+ const limitMs = timedSessions.get(session) ?? 0;
366
+ const timed = limitMs > 0;
367
+ let outOfTime = false;
368
+ let timer;
369
+ let backstop;
370
+ let stopped = false;
371
+ const endTurn = (how) => {
372
+ if (stopped)
373
+ return;
374
+ stopped = true;
375
+ reportActivity(` stopped the agent's turn: its ${formatLimit(limitMs)} are up`);
376
+ debug?.note(`turn stopped: its ${formatLimit(limitMs)} are up${how}`);
377
+ void session.abort();
378
+ };
379
+ const atNextRequest = timed
380
+ ? session.subscribe((event) => {
381
+ if (outOfTime && event.type === "turn_start")
382
+ endTurn("");
383
+ })
384
+ : undefined;
385
+ if (timed) {
386
+ timer = setTimeout(() => {
387
+ outOfTime = true;
388
+ backstop = setTimeout(() => endTurn(" - and the grace after it: ended where it was"), TURN_GRACE_MS);
389
+ }, limitMs);
390
+ }
391
+ // What the model is sent, and only that: its task is its system prompt, which the log already holds whole.
392
+ debug?.prompt(text);
393
+ try {
394
+ // Its images, when its conversation does not hold them: the first turn, and the first after a compaction.
395
+ const images = sessionImages.get(session);
396
+ const attach = images !== undefined && !images.sent ? images.images : undefined;
397
+ // The log holds what the model is sent as text; an image is not text, so the log says it went too.
398
+ if (attach !== undefined)
399
+ debug?.note(`sent with this message: ${attach.length === 1 ? "1 image" : `${attach.length} images`} (${attach.map((image) => image.mimeType).join(", ")})`);
400
+ await session.prompt(text, { expandPromptTemplates: false, ...(attach !== undefined ? { images: attach } : {}) });
401
+ if (images !== undefined)
402
+ images.sent = true;
403
+ }
404
+ catch (error) {
405
+ debug?.note(`turn failed after ${seconds(Date.now() - turnStarted)}: ${error instanceof Error ? error.message : String(error)}`);
406
+ throw error;
407
+ }
408
+ finally {
409
+ if (timer !== undefined)
410
+ clearTimeout(timer);
411
+ if (backstop !== undefined)
412
+ clearTimeout(backstop);
413
+ atNextRequest?.();
414
+ unsubscribe();
415
+ stop?.removeEventListener("abort", onStop);
416
+ reportNow(undefined);
417
+ }
418
+ if (stopped)
419
+ out = `${out.trim()}\n\n[the turn was stopped: its ${formatLimit(limitMs)} were up]`.trim();
420
+ if (stop?.aborted) {
421
+ debug?.note(`turn stopped on request after ${seconds(Date.now() - turnStarted)}`);
422
+ throw new JobStoppedError();
423
+ }
424
+ // A turn ended for its time is ended, whatever the provider was doing then.
425
+ if (providerError !== undefined && !stopped) {
426
+ debug?.note(`turn ended with a provider error after ${seconds(Date.now() - turnStarted)}: ${providerError}`);
427
+ throw new ProviderError(`${session.model?.id ?? "model"}: ${providerError}`);
428
+ }
429
+ debug?.note(`turn finished after ${seconds(Date.now() - turnStarted)}`);
430
+ return out.trim();
431
+ }
432
+ /**
433
+ * How long one turn of an agent a render starts may take: past it, the turn ends where it is and
434
+ * the harness goes on as after any turn - it runs the tests, and judges. The config's `turn-limit:`
435
+ * sets it, in seconds, and each role's in `models:`; 0 turns it off.
436
+ */
437
+ export const DEFAULT_TURN_LIMIT_MS = 120 * 1000;
438
+ /** How long past the turn limit what is running then - the model answering, a tool - may take to finish. */
439
+ export const TURN_GRACE_MS = Number(process.env.TURRI_TURN_GRACE ?? 60) * 1000;
440
+ /** A session's task, in its instructions, until its first turn shows it in the transcript. */
441
+ const pendingTask = new WeakMap();
442
+ /** A session's images, and whether its conversation holds them now: not at first, nor after a compaction. */
443
+ const sessionImages = new WeakMap();
444
+ /**
445
+ * Refuses, before any agent of a job starts, a named model that reads no images when the job's spec links some -
446
+ * rather than once the work is under way. Unnamed, the model is pi's default: `createExecutor` refuses it then.
447
+ */
448
+ export async function refuseIfModelIsBlind(modelSpec, count) {
449
+ if (count === 0)
450
+ return;
451
+ const chosen = splitModelSpec(modelSpec).model;
452
+ const slash = chosen?.indexOf("/") ?? -1;
453
+ if (chosen === undefined || slash === -1)
454
+ return;
455
+ refuseIfBlind(new ModelRegistry(await subAgentModelRuntime()).find(chosen.slice(0, slash), chosen.slice(slash + 1)), count);
456
+ }
457
+ /** A model that cannot read images, given images to read. */
458
+ export class ModelCannotReadImagesError extends Error {
459
+ }
460
+ /**
461
+ * Stops an agent whose model reads no images being given some: it would work blind from the spec's images. Says
462
+ * which model, and what to do - a coding model that reads images, or the images described in text instead.
463
+ */
464
+ export function refuseIfBlind(model, count) {
465
+ if (model === undefined || model.input?.includes("image") === true)
466
+ return;
467
+ throw new ModelCannotReadImagesError(`The spec links ${count === 1 ? "an image" : `${count} images`} (PNG or JPEG), and ${model.provider}/${model.id} cannot read images. ` +
468
+ "Set coding-model in config.turri.yaml to a model that reads images, or describe what the image shows in a text file and link that instead.");
469
+ }
470
+ /**
471
+ * What an agent opens with when its task is in its instructions. The task - the spec, how to read it, what to do
472
+ * and the rules - is its system prompt, not its first message: compaction sums up the conversation and never the
473
+ * system prompt, so what the agent works from stays word for word as it was, however long the work runs, and stays
474
+ * in the cached prefix of every call.
475
+ */
476
+ export const BEGIN = "Begin. Your task is in your instructions.";
477
+ /** Tokens of conversation past which a session that keeps working is compacted, when the project does not say. */
478
+ export const DEFAULT_COMPACT_AT = 100_000;
479
+ const compactions = new WeakMap();
480
+ /** Have a session compacted between turns, as `compaction` says: what createExecutor does for an agent given one. */
481
+ export function keepCompacted(session, compaction) {
482
+ compactions.set(session, compaction);
483
+ }
484
+ /** A rough count, as pi's own estimate makes it: four characters a token. */
485
+ function tokensIn(text) {
486
+ return Math.ceil(text.length / 4);
487
+ }
488
+ function thousands(tokens) {
489
+ return tokens >= 1000 ? `${Math.round(tokens / 1000)}k` : `${tokens}`;
490
+ }
491
+ /**
492
+ * Compact a session's work when its conversation - what it has done, not its instructions - has got larger than its
493
+ * threshold. An agent that keeps its session through a job re-reads everything it did on every call: this keeps
494
+ * that bounded, as pi's own compaction does only when the context window is nearly full. The summary keeps what
495
+ * its role needs; its instructions are untouched. A compaction that fails leaves the session as it was.
496
+ */
497
+ export async function compactIfDue(session) {
498
+ const settings = compactions.get(session);
499
+ if (settings === undefined)
500
+ return false;
501
+ let tokens;
502
+ try {
503
+ tokens = session.getContextUsage()?.tokens;
504
+ }
505
+ catch {
506
+ return false;
507
+ }
508
+ if (tokens === null || tokens === undefined)
509
+ return false;
510
+ const conversation = tokens - tokensIn(session.systemPrompt ?? "");
511
+ if (conversation < settings.atTokens)
512
+ return false;
513
+ const agent = agentOf(session);
514
+ const who = agent?.label ?? "the agent";
515
+ reportActivity(` compacting ${who}'s work: ${thousands(conversation)} tokens of it, past ${thousands(settings.atTokens)}`);
516
+ try {
517
+ const result = await session.compact(settings.instructions);
518
+ const usage = result.usage;
519
+ if (usage !== undefined) {
520
+ const spent = { input: usage.input, output: usage.output, cacheRead: usage.cacheRead, cacheWrite: usage.cacheWrite, cost: usage.cost?.total ?? 0 };
521
+ reportUsage(spent);
522
+ if (agent !== undefined)
523
+ reportAgent({ label: agent.label, usage: spent });
524
+ }
525
+ if (agent !== undefined)
526
+ reportAgent({ label: agent.label, compactions: 1 });
527
+ reportActivity(` compacted ${who}'s work${result.estimatedTokensAfter !== undefined ? ` to about ${thousands(result.estimatedTokensAfter)} tokens` : ""}`);
528
+ debugLogOf(session)?.note(`compacted: ${result.tokensBefore} tokens before${result.estimatedTokensAfter !== undefined ? `, about ${result.estimatedTokensAfter} after` : ""}`);
529
+ return true;
530
+ }
531
+ catch (error) {
532
+ reportActivity(` could not compact ${who}'s work - it goes on as it is: ${error instanceof Error ? error.message.slice(0, 160) : String(error)}`);
533
+ return false;
534
+ }
535
+ }
536
+ /** The sessions whose turns are timed, and how long each turn may take: the agents a render starts, not the conversation or a skill's. */
537
+ const timedSessions = new WeakMap();
538
+ /** Which agent a session is, and what it runs on - for its transcript entries. */
539
+ const agentLabels = new WeakMap();
540
+ /** Each agent's role, to say on its activity lines which agent did what. */
541
+ const agentRoles = new WeakMap();
542
+ function agentOf(session) {
543
+ return agentLabels.get(session);
544
+ }
545
+ /**
546
+ * Have this session's turns timed, as an agent a render starts has: `limitMs` each - its role's in the config - or
547
+ * the default.
548
+ */
549
+ export function timeTurns(session, limitMs) {
550
+ timedSessions.set(session, limitMs ?? DEFAULT_TURN_LIMIT_MS);
551
+ }
552
+ /** How many calls to one tool may fail in a row, nothing going through, before the agent's turn is stopped. */
553
+ export const FAILURES_BEFORE_STOP = 10;
554
+ /** How many times running the very same tool call is let through before it is refused, and before the turn is stopped. */
555
+ export const REPEATS_ALLOWED = 3;
556
+ export const REPEATS_BEFORE_STOP = 6;
557
+ /** "2 minutes", "90 seconds". */
558
+ function formatLimit(ms) {
559
+ const seconds = Math.round(ms / 1000);
560
+ if (seconds % 60 === 0)
561
+ return `${seconds / 60} minute${seconds === 60 ? "" : "s"}`;
562
+ return `${seconds} second${seconds === 1 ? "" : "s"}`;
563
+ }
564
+ /** JSON with every object's keys in order, so two ways of writing the same arguments read the same. */
565
+ export function canonicalJson(value) {
566
+ return JSON.stringify(value, (_key, item) => item !== null && typeof item === "object" && !Array.isArray(item)
567
+ ? Object.fromEntries(Object.entries(item).sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0)))
568
+ : item);
569
+ }
570
+ /** Tools that only look: calling one again, with nothing changed since, gives the same answer. */
571
+ const LOOKING_TOOLS = new Set(["read", "grep", "find", "ls"]);
572
+ /**
573
+ * Tools that notice an agent going round in a circle. The same call again and again with nothing in
574
+ * between; or a look - read, grep, find, ls - made again with nothing changed since, however many
575
+ * other looks came between: the same answer comes back. The first few go through; after that the
576
+ * call is refused with what is happening, and if the agent carries on regardless its turn is
577
+ * stopped - the harness goes on from there, as after any turn.
578
+ *
579
+ * With a budget, an agent that has one thing to decide - a verdict, a review, an account - is told
580
+ * to decide once it has made that many calls: every tool but the one that hands it over refuses,
581
+ * and past twice the budget its turn is stopped. Handing it over starts the count again, and forgets
582
+ * the looks: a reviewer kept for the next review reads the files as they are by then.
583
+ */
584
+ export function guardRepeats(tools, session, budget) {
585
+ let last;
586
+ let times = 0;
587
+ // Looks made since the last call that could change anything, and how often each.
588
+ const looks = new Map();
589
+ let calls = 0;
590
+ // Calls refused in the restricted turn under way, and which turn's set that was.
591
+ let refused = 0;
592
+ let refusedIn;
593
+ return tools.map((tool) => ({
594
+ ...tool,
595
+ execute: async (...args) => {
596
+ const [, params] = args;
597
+ const current = session();
598
+ const only = current !== undefined ? turnTools.get(current) : undefined;
599
+ if (only !== undefined && !only.has(tool.name)) {
600
+ if (refusedIn !== only)
601
+ (refused = 0), (refusedIn = only);
602
+ refused += 1;
603
+ if (refused > REFUSED_BEFORE_STOP) {
604
+ reportActivity(` stopped the agent's turn: ${refused} calls to tools this turn does not have`);
605
+ void current?.abort();
606
+ throw new Error(`Stopped: ${refused} calls to tools this turn does not have.`);
607
+ }
608
+ throw new Error(`Not run: your work is done and checked - this turn is for ${[...only].join(" and ")} alone, and nothing else is ` +
609
+ `run. Call ${[...only].join(" or ")}, then finish.`);
610
+ }
611
+ calls += 1;
612
+ if (budget !== undefined && !budget.finish.includes(tool.name) && calls > budget.calls) {
613
+ if (calls > budget.calls * 2) {
614
+ reportActivity(` stopped the agent's turn: ${calls} tool calls, past its budget of ${budget.calls}`);
615
+ void session()?.abort();
616
+ throw new Error(`Stopped: ${calls} tool calls, past the budget of ${budget.calls}.`);
617
+ }
618
+ throw new Error(`Not run: you have made ${budget.calls} tool calls, which is all you have. Decide now with what you know: call ` +
619
+ `${budget.finish.join(" or ")}.`);
620
+ }
621
+ let key;
622
+ try {
623
+ // The same call is the same whatever order its arguments come in: models reorder them freely.
624
+ key = `${tool.name} ${canonicalJson(params ?? {})}`;
625
+ }
626
+ catch {
627
+ key = `${tool.name} ${String(Math.random())}`;
628
+ }
629
+ times = key === last ? times + 1 : 1;
630
+ last = key;
631
+ let repeats = times;
632
+ if (LOOKING_TOOLS.has(tool.name)) {
633
+ const seen = (looks.get(key) ?? 0) + 1;
634
+ looks.set(key, seen);
635
+ repeats = Math.max(repeats, seen);
636
+ }
637
+ else {
638
+ // Anything else may have changed what a look would find.
639
+ looks.clear();
640
+ }
641
+ if (repeats > REPEATS_BEFORE_STOP) {
642
+ reportActivity(` stopped the agent's turn: it made the same ${tool.name} call ${repeats} times with nothing changed`);
643
+ void session()?.abort();
644
+ throw new Error(`Stopped: the same ${tool.name} call ${repeats} times with nothing changed.`);
645
+ }
646
+ if (repeats > REPEATS_ALLOWED) {
647
+ reportActivity(` refused a repeated ${tool.name} call (${repeats} times with nothing changed)`);
648
+ throw new Error(`Not run: you have made this exact ${tool.name} call ${repeats} times, with nothing changed since - so it would give ` +
649
+ "the same result again. Stop repeating it: use what it already told you, do something different, or finish. " +
650
+ `After ${REPEATS_BEFORE_STOP} your turn is stopped.`);
651
+ }
652
+ const result = await tool.execute(...args);
653
+ // The decision handed over ends the round: a session kept for the next one starts it with its whole budget.
654
+ if (budget?.finish.includes(tool.name)) {
655
+ calls = 0;
656
+ last = undefined;
657
+ times = 0;
658
+ looks.clear();
659
+ }
660
+ return result;
661
+ },
662
+ }));
663
+ }
664
+ /**
665
+ * The executor. One session for the whole skill run, so a retry after a failed
666
+ * gate arrives as a follow-up turn with the critique in it rather than as a cold
667
+ * start that has forgotten what it just wrote.
668
+ */
669
+ export async function createExecutor(opts) {
670
+ // Saved to disk, in the project: an agent's session is the record of what it did, and the
671
+ // only way to pick a long run back up after the process that ran it is gone.
672
+ const sessionManager = opts.resumeFrom !== undefined
673
+ ? SessionManager.open(opts.resumeFrom)
674
+ : SessionManager.create(opts.cwd, sessionsDir(projectRootOf(opts.cwd)));
675
+ // An agent that writes and runs code - given the harness's run its reports are checked against - can say the machine
676
+ // lacks what the work needs: its turn ends, and the pulse stops with it. Observers, explorers and judges cannot.
677
+ const hasShell = opts.customTools.some((tool) => tool.name === "bash") && opts.checkEnvironment !== undefined;
678
+ const reported = {};
679
+ const check = opts.checkEnvironment !== undefined ? checkAgainstScript(opts.checkEnvironment.run, opts.checkEnvironment.script) : undefined;
680
+ const withEnvironmentErrors = hasShell ? [...opts.customTools, environmentErrorTool((report) => (reported.report = report), check)] : opts.customTools;
681
+ const { session } = await createAgentSession({
682
+ cwd: opts.cwd,
683
+ modelRuntime: await subAgentModelRuntime(),
684
+ tools: hasShell ? [...opts.tools, ENVIRONMENT_ERROR_TOOL] : opts.tools,
685
+ // What an agent is shown never holds a credential the project passed: each value is its name (masking.ts).
686
+ customTools: guardRepeats(maskToolResults(withEnvironmentErrors), () => session, opts.budget),
687
+ sessionManager,
688
+ resourceLoader: await subAgentResources(opts.cwd, opts.rules === true, [opts.system, opts.task].filter((part) => part !== undefined && part !== "").join("\n\n") || undefined),
689
+ });
690
+ if (opts.task !== undefined)
691
+ pendingTask.set(session, opts.task);
692
+ if (hasShell)
693
+ environmentReports.set(session, { cwd: opts.cwd, reported });
694
+ if (opts.compaction !== undefined)
695
+ keepCompacted(session, { instructions: opts.compaction.instructions, atTokens: opts.compaction.atTokens ?? compactAtFor(opts.cwd) });
696
+ // Its role's own model and thinking, where the config names them; otherwise what it was given.
697
+ const config = opts.role !== undefined ? configForCwd(opts.cwd) : undefined;
698
+ const fromRole = config !== undefined && opts.role !== undefined ? roleModel(config, opts.role) : undefined;
699
+ const chosen = fromRole !== undefined ? (inheritModel(fromRole, splitModelSpec(opts.model)) ?? {}) : splitModelSpec(opts.model);
700
+ if (chosen.model !== undefined) {
701
+ const model = findModel(session, chosen.model);
702
+ if (model === undefined) {
703
+ throw new Error(`Model '${chosen.model}' is not configured. Check the provider's API key.`);
704
+ }
705
+ await session.setModel(model);
706
+ }
707
+ // After the model: switching models picks a thinking level of its own.
708
+ const thinking = agentThinking(chosen.thinking, opts.role, inheritedThinkingLevel);
709
+ if (thinking !== undefined)
710
+ session.setThinkingLevel(thinking);
711
+ if (opts.images !== undefined && opts.images.length > 0) {
712
+ refuseIfBlind(session.model, opts.images.length);
713
+ sessionImages.set(session, { images: opts.images, sent: false });
714
+ }
715
+ // After both, so the log's header says what it really runs on.
716
+ if (opts.label !== undefined)
717
+ startDebugLog(session, { cwd: opts.cwd, label: opts.label, tools: opts.tools });
718
+ // An agent a render starts - it has a label - has its turns timed: as long as its role's turns may take.
719
+ if (opts.label !== undefined)
720
+ timeTurns(session, config !== undefined ? roleTurnLimitMs(config, opts.role) : undefined);
721
+ sendReasoningEffort(session);
722
+ if (opts.role !== undefined)
723
+ agentRoles.set(session, opts.role);
724
+ if (opts.label !== undefined) {
725
+ const thinking = session.thinkingLevel;
726
+ agentLabels.set(session, { label: opts.label, model: session.model !== undefined ? `${session.model.provider}/${session.model.id}${thinking ? `:${thinking}` : ""}` : undefined });
727
+ }
728
+ return session;
729
+ }
730
+ /** The config an agent working in `cwd` is under: the project's, with the chosen stack's over it. */
731
+ function configForCwd(cwd) {
732
+ try {
733
+ const root = projectRootOf(cwd);
734
+ return readProjectConfig(root, stackOf(root));
735
+ }
736
+ catch {
737
+ return {};
738
+ }
739
+ }
740
+ /** pi's thinking levels as the reasoning effort OpenAI's models on Bedrock take. */
741
+ const BEDROCK_OPENAI_EFFORT = { off: "none", minimal: "low", low: "low", medium: "medium", high: "high", xhigh: "xhigh", max: "xhigh" };
742
+ /**
743
+ * OpenAI's models on Bedrock are sent the session's thinking level as their reasoning effort - pi sends none for a
744
+ * model that is not Anthropic's, and they would think at their own default whatever the config said.
745
+ */
746
+ function sendReasoningEffort(session) {
747
+ const agent = session.agent;
748
+ const before = agent.onPayload;
749
+ agent.onPayload = async (payload, model) => withReasoningEffort((await before?.(payload, model)) ?? payload, model, session.thinkingLevel);
750
+ }
751
+ /** A provider request with its thinking level sent as reasoning effort, where the model takes one turri sends itself. */
752
+ export function withReasoningEffort(payload, model, thinkingLevel) {
753
+ const m = model;
754
+ const effort = BEDROCK_OPENAI_EFFORT[thinkingLevel ?? ""];
755
+ if (m?.api !== "bedrock-converse-stream" || m.reasoning !== true || !/(^|\.)openai\./.test(m.id ?? "") || effort === undefined)
756
+ return payload;
757
+ if (payload === null || typeof payload !== "object")
758
+ return payload;
759
+ const input = payload;
760
+ return { ...input, additionalModelRequestFields: { ...(input.additionalModelRequestFields ?? {}), reasoning: { effort } } };
761
+ }
762
+ function compactAtFor(cwd) {
763
+ try {
764
+ return readProjectConfig(projectRootOf(cwd)).compactAt ?? DEFAULT_COMPACT_AT;
765
+ }
766
+ catch {
767
+ return DEFAULT_COMPACT_AT;
768
+ }
769
+ }
770
+ function findModel(session, spec) {
771
+ const slash = spec.indexOf("/");
772
+ if (slash === -1)
773
+ throw new Error(`Model must be "provider/model-id", got "${spec}"`);
774
+ return new ModelRegistry(session.modelRuntime).find(spec.slice(0, slash), spec.slice(slash + 1));
775
+ }
776
+ /**
777
+ * A judge. Fresh session, one tool, and - deliberately - no access to the
778
+ * executor's transcript. A judge that inherits the actor's reasoning inherits
779
+ * its rationalisations, so it gets the artifact and the rubric, nothing else.
780
+ */
781
+ export async function createJudge(opts) {
782
+ const modelRuntime = opts.borrowAuthFrom.modelRuntime;
783
+ let model;
784
+ let modelNote = "executor model";
785
+ if (opts.modelSpec) {
786
+ const slash = opts.modelSpec.indexOf("/");
787
+ if (slash === -1)
788
+ throw new Error(`gate model must be "provider/model-id", got "${opts.modelSpec}"`);
789
+ const provider = opts.modelSpec.slice(0, slash);
790
+ const modelId = opts.modelSpec.slice(slash + 1);
791
+ const found = new ModelRegistry(modelRuntime).find(provider, modelId);
792
+ if (found) {
793
+ model = found;
794
+ modelNote = opts.modelSpec;
795
+ }
796
+ else {
797
+ modelNote = `executor model (${opts.modelSpec} not configured)`;
798
+ }
799
+ }
800
+ const { session } = await createAgentSession({
801
+ cwd: opts.cwd,
802
+ model,
803
+ modelRuntime,
804
+ tools: [...(opts.extraTools ?? []), "submit_verdict"],
805
+ customTools: [opts.verdictTool],
806
+ sessionManager: SessionManager.inMemory(opts.cwd),
807
+ // Judges assess specs against the rules, so they have them too.
808
+ resourceLoader: await subAgentResources(opts.cwd, true, opts.task),
809
+ });
810
+ if (opts.task !== undefined)
811
+ pendingTask.set(session, opts.task);
812
+ return { session, modelNote };
813
+ }