jeo-code 0.7.33 → 0.7.35
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +24 -1
- package/README.ja.md +2 -2
- package/README.ko.md +2 -2
- package/README.md +2 -2
- package/README.zh.md +2 -2
- package/package.json +1 -1
- package/src/agent/AGENTS.md +1 -1
- package/src/agent/plan.ts +27 -1
- package/src/agent/task-tool.ts +168 -93
- package/src/ai/providers/anthropic.ts +2 -1
- package/src/ai/providers/antigravity.ts +3 -2
- package/src/ai/providers/gemini.ts +3 -2
- package/src/ai/providers/ollama.ts +2 -1
- package/src/ai/providers/openai-responses.ts +3 -2
- package/src/ai/providers/openai.ts +2 -1
- package/src/commands/AGENTS.md +1 -1
- package/src/commands/launch.ts +46 -3
- package/src/commands/ralplan.ts +11 -1
- package/src/commands/team.ts +266 -79
- package/src/util/sanitize-json.ts +42 -0
package/CHANGELOG.md
CHANGED
|
@@ -6,6 +6,30 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
|
|
6
6
|
|
|
7
7
|
The README mirrors the latest 5 entries — regenerate with `bun run changelog:sync`.
|
|
8
8
|
|
|
9
|
+
## [0.7.35] - 2026-07-04
|
|
10
|
+
_`jeo team` now runs every plan step through the SAME subagent execution core as the `task`/`subagent` tools, and gains gjc-style concurrent workers: a plan can mark a contiguous run of independent steps to execute in parallel, each isolated in its own git worktree, merged back in order._
|
|
11
|
+
|
|
12
|
+
### Added
|
|
13
|
+
- **`parallel_group` — opt-in concurrent plan-step execution for `jeo team` (gjc `team` concurrency parity), git-worktree isolated.** `src/agent/plan.ts`'s `StepSchema` gains an optional `parallel_group: string` field; `PlanSchema` validates that steps sharing the same non-empty value are mutually CONTIGUOUS (a split group has no coherent execution position and is rejected with a clear Zod issue). `jeo team`'s executor (`runParallelGroup` in `src/commands/team.ts`) dispatches a contiguous group concurrently: each step gets its own git worktree (via the existing `resolveWorktree`, same helper `jeo launch --worktree` uses) and runs through `new SubagentRegistry()` + `runSubagentOnce()` — the identical core the `task`/`subagent` tools and the serial team path use. On full group success, each worker's committed branch is merged back into the current branch IN ARRAY ORDER (`git merge --no-ff`); any step failure OR merge conflict stops immediately, aborts any in-flight merge (`git merge --abort`, never auto-resolved), and leaves every remaining worktree/branch untouched for manual inspection. `jeo ralplan`'s plan-generation prompt now documents `parallel_group` (off by default; only for steps touching disjoint files with no cross-step dependency) so the field is reachable from the normal spec-first workflow, not just hand-authored plans.
|
|
14
|
+
|
|
15
|
+
### Changed
|
|
16
|
+
- **`jeo team`'s step executor now runs through the SAME execution core as the `task`/`subagent` tools instead of a second hand-rolled copy of the subagent loop.** `runSubagentOnce` (new, exported from `src/agent/task-tool.ts`) is the extracted single execution core — it builds the role's history, runs `runAgentLoop`, validates the done-reason contract, audits mutation evidence, and fences the report against prompt injection — used by `task-tool.ts`'s single/fan-out/detached paths AND `jeo team`'s `executeTaskWithAgent`/`runParallelGroup`. `SubagentRunResult` (a superset of `ToolResult` exposing the raw done reason, contract-ok flag, and mutation-audit counters) lets `jeo team` keep its own role-gate-verdict parsing (architect BLOCK / critic REQUEST CHANGES) and `--strict-mutations` policy on top without re-deriving them from formatted report text. A previously team-only LLM-summarization compaction pass (`maybeCompact`) is no longer called — team subagents now rely solely on the agent loop's own built-in context guard, the same as every other subagent, instead of a second, divergent compaction strategy.
|
|
17
|
+
|
|
18
|
+
### Verified
|
|
19
|
+
- `bun run typecheck` clean; full suite green (2282 tests).
|
|
20
|
+
- New coverage: `test/team-parallel.test.ts` (real git operations in throwaway temp repos — no git mocking — covering a successful 2-step concurrent group merging both branches, a failing step leaving the group unmerged with the succeeding step's worktree intact, and a genuine merge conflict aborting cleanly with no partial state) plus `PlanSchema` contiguity validation cases. `test/team-run.test.ts` updated for the consolidated execution core's report formatting (contract-incomplete detail now embedded in the fenced report; the obsolete `maybeCompact`-invocation test was removed as an intentional behavior change, not a regression).
|
|
21
|
+
|
|
22
|
+
## [0.7.34] - 2026-07-04
|
|
23
|
+
_gajae-code 0.8.0/0.8.1 parity audit: closes a real cross-provider lone-surrogate JSON bug and makes turn-boundary compaction Esc-cancellable; the rest of gjc's 0.8.x release (Telegram daemon, ACP, cmux, coordinator MCP, tool-discovery-mode, an internal paged transcript viewport) is gjc-specific architecture jeo intentionally does not replicate, or was already-equivalent (confirmed by a 4-way parallel audit: ast_grep formatting, session-picker empty-filter clamping, PageUp/PageDown)._
|
|
24
|
+
|
|
25
|
+
### Fixed
|
|
26
|
+
- **A lone (unpaired) UTF-16 surrogate anywhere in tool-call arguments, tool-result output, or message content could break an outgoing provider request (gjc 0.8.1 parity).** Truncating tool output by UTF-16 code units (`tool-output.ts`/`output-util.ts`) can split an emoji's surrogate pair; the orphaned half survived `JSON.stringify` as an unpaired `\ud8xx`-style escape that strict provider JSON parsers reject (e.g. Anthropic 400 "no low surrogate in string"). New `src/util/sanitize-json.ts` (`sanitizeJsonStrings`, a `WeakMap`-guarded recursive walker applying `String.prototype.toWellFormed()` to every string value and object key, ported from gajae-code's shared sanitizer) is now applied at every provider's terminal request-body `JSON.stringify` — Anthropic, Gemini (+ Gemini CLI), OpenAI, OpenAI Responses/Codex, Antigravity, and Ollama.
|
|
27
|
+
- **Turn-boundary compaction could not be cancelled with Esc/Ctrl+C (gjc 0.8.0 "Escape now reliably cancels active context maintenance" parity).** `maybeCompact`'s preflight call in `runTurn` ran before the turn's main abort harness existed, so a slow/hung compaction summarization LLM call had no cancellation path. It now runs under a short-lived abort harness (disposed in a `finally` immediately after) whose signal is threaded into `maybeCompact`, without changing when or how the main per-turn harness is created.
|
|
28
|
+
|
|
29
|
+
### Verified
|
|
30
|
+
- `bun run typecheck` clean; full suite green.
|
|
31
|
+
- New coverage: `test/sanitize-json.test.ts` (lone high/low surrogate values, a well-formed emoji pair preserved unchanged, nested-object/array-element/object-key surrogates, reference-cycle safety, and an end-to-end Anthropic request-body round-trip); `test/compaction.test.ts` extended with a mid-flight abort case and a structural guard on the `launch.ts` call site's signal wiring.
|
|
32
|
+
|
|
9
33
|
## [0.7.33] - 2026-07-04
|
|
10
34
|
_Adds `browser` — headless Chromium automation (gjc `browser` parity) — completing the gjc tool-surface parity pass started in 0.7.32._
|
|
11
35
|
|
|
@@ -41,7 +65,6 @@ _A guardrail audit of the agent loop, subagent spawn gate, and file-edit tools t
|
|
|
41
65
|
|
|
42
66
|
## [0.7.31] - 2026-07-03
|
|
43
67
|
_Three-round speed & memory pass across streaming, memory retrieval, and the TUI render loop (O(n²) → O(n) in several hot paths), a deep-interview repeat-question loop guard, and gajae-code managed-tmux launch sizing parity._
|
|
44
|
-
|
|
45
68
|
### Performance
|
|
46
69
|
- **Bash tool output streaming no longer accumulates without bound.** `drainPipe` (`src/agent/tools.ts`) stops growing its buffer once it reaches the `bashTool` truncation cap and keeps only a small rolling 16KB tail for the live view afterward — a chatty long-running command no longer holds hundreds of MB in memory for the whole timeout window, and the live `onChunk` callback receives a bounded string instead of the full cumulative buffer (was making every downstream sanitize/wrap pass O(n²) over stream length).
|
|
47
70
|
- **Engine stream emission is throttled (~80ms) instead of firing per token.** `onModelStream`/`onReasoningStream` in `src/agent/engine.ts` no longer re-flatten the growing response rope and re-scan it in the TUI on every streamed token; a guaranteed final flush (`flushStreams()`) delivers the complete text before parse/commit so no content is ever lost to the throttle. Per-step call-signature digests are now computed once and reused across the repeat/cycle/step-budget novelty checks instead of being re-hashed three times.
|
package/README.ja.md
CHANGED
|
@@ -207,11 +207,11 @@ CI は `.github/workflows/npm-publish.yml` で公開します — GitHub リリ
|
|
|
207
207
|
## 変更履歴 (Changelog)
|
|
208
208
|
|
|
209
209
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
210
|
+
- **[0.7.35]** (2026-07-04) — `jeo team` now runs every plan step through the SAME subagent execution core as the `task`/`subagent` tools, and gains gjc-style concurrent workers: a plan can mark a contiguous run of independent steps to execute in parallel, each isolated in its own git worktree, merged back in order.
|
|
211
|
+
- **[0.7.34]** (2026-07-04) — gajae-code 0.8.0/0.8.1 parity audit: closes a real cross-provider lone-surrogate JSON bug and makes turn-boundary compaction Esc-cancellable; the rest of gjc's 0.8.x release (Telegram daemon, ACP, cmux, coordinator MCP, tool-discovery-mode, an internal paged transcript viewport) is gjc-specific architecture jeo intentionally does not replicate, or was already-equivalent (confirmed by a 4-way parallel audit: ast_grep formatting, session-picker empty-filter clamping, PageUp/PageDown).
|
|
210
212
|
- **[0.7.33]** (2026-07-04) — Adds `browser` — headless Chromium automation (gjc `browser` parity) — completing the gjc tool-surface parity pass started in 0.7.32.
|
|
211
213
|
- **[0.7.32]** (2026-07-03) — gjc tool-surface parity — ast_grep/ast_edit, lsp/lsp_rename, debug, irc, job, and goal tools — plus a `launch.ts` slash-command refactor and four agent-loop guardrail hardenings (destructive-bash denylist, spawn-gate justification quality check, done-verification abuse guard, and a blind unanchored line-range-edit guard).
|
|
212
214
|
- **[0.7.31]** (2026-07-03) — Three-round speed & memory pass across streaming, memory retrieval, and the TUI render loop (O(n²) → O(n) in several hot paths), a deep-interview repeat-question loop guard, and gajae-code managed-tmux launch sizing parity.
|
|
213
|
-
- **[0.7.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
|
|
214
|
-
- **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
|
|
215
215
|
|
|
216
216
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
217
217
|
<!-- CHANGELOG:END -->
|
package/README.ko.md
CHANGED
|
@@ -207,11 +207,11 @@ CI는 `.github/workflows/npm-publish.yml`로 배포합니다 — GitHub 릴리
|
|
|
207
207
|
## 변경 이력 (Changelog)
|
|
208
208
|
|
|
209
209
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
210
|
+
- **[0.7.35]** (2026-07-04) — `jeo team` now runs every plan step through the SAME subagent execution core as the `task`/`subagent` tools, and gains gjc-style concurrent workers: a plan can mark a contiguous run of independent steps to execute in parallel, each isolated in its own git worktree, merged back in order.
|
|
211
|
+
- **[0.7.34]** (2026-07-04) — gajae-code 0.8.0/0.8.1 parity audit: closes a real cross-provider lone-surrogate JSON bug and makes turn-boundary compaction Esc-cancellable; the rest of gjc's 0.8.x release (Telegram daemon, ACP, cmux, coordinator MCP, tool-discovery-mode, an internal paged transcript viewport) is gjc-specific architecture jeo intentionally does not replicate, or was already-equivalent (confirmed by a 4-way parallel audit: ast_grep formatting, session-picker empty-filter clamping, PageUp/PageDown).
|
|
210
212
|
- **[0.7.33]** (2026-07-04) — Adds `browser` — headless Chromium automation (gjc `browser` parity) — completing the gjc tool-surface parity pass started in 0.7.32.
|
|
211
213
|
- **[0.7.32]** (2026-07-03) — gjc tool-surface parity — ast_grep/ast_edit, lsp/lsp_rename, debug, irc, job, and goal tools — plus a `launch.ts` slash-command refactor and four agent-loop guardrail hardenings (destructive-bash denylist, spawn-gate justification quality check, done-verification abuse guard, and a blind unanchored line-range-edit guard).
|
|
212
214
|
- **[0.7.31]** (2026-07-03) — Three-round speed & memory pass across streaming, memory retrieval, and the TUI render loop (O(n²) → O(n) in several hot paths), a deep-interview repeat-question loop guard, and gajae-code managed-tmux launch sizing parity.
|
|
213
|
-
- **[0.7.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
|
|
214
|
-
- **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
|
|
215
215
|
|
|
216
216
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
217
217
|
<!-- CHANGELOG:END -->
|
package/README.md
CHANGED
|
@@ -234,11 +234,11 @@ Huge thanks to [gajae-code](https://github.com/Yeachan-Heo/gajae-code) for the i
|
|
|
234
234
|
## Changelog
|
|
235
235
|
|
|
236
236
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
237
|
+
- **[0.7.35]** (2026-07-04) — `jeo team` now runs every plan step through the SAME subagent execution core as the `task`/`subagent` tools, and gains gjc-style concurrent workers: a plan can mark a contiguous run of independent steps to execute in parallel, each isolated in its own git worktree, merged back in order.
|
|
238
|
+
- **[0.7.34]** (2026-07-04) — gajae-code 0.8.0/0.8.1 parity audit: closes a real cross-provider lone-surrogate JSON bug and makes turn-boundary compaction Esc-cancellable; the rest of gjc's 0.8.x release (Telegram daemon, ACP, cmux, coordinator MCP, tool-discovery-mode, an internal paged transcript viewport) is gjc-specific architecture jeo intentionally does not replicate, or was already-equivalent (confirmed by a 4-way parallel audit: ast_grep formatting, session-picker empty-filter clamping, PageUp/PageDown).
|
|
237
239
|
- **[0.7.33]** (2026-07-04) — Adds `browser` — headless Chromium automation (gjc `browser` parity) — completing the gjc tool-surface parity pass started in 0.7.32.
|
|
238
240
|
- **[0.7.32]** (2026-07-03) — gjc tool-surface parity — ast_grep/ast_edit, lsp/lsp_rename, debug, irc, job, and goal tools — plus a `launch.ts` slash-command refactor and four agent-loop guardrail hardenings (destructive-bash denylist, spawn-gate justification quality check, done-verification abuse guard, and a blind unanchored line-range-edit guard).
|
|
239
241
|
- **[0.7.31]** (2026-07-03) — Three-round speed & memory pass across streaming, memory retrieval, and the TUI render loop (O(n²) → O(n) in several hot paths), a deep-interview repeat-question loop guard, and gajae-code managed-tmux launch sizing parity.
|
|
240
|
-
- **[0.7.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
|
|
241
|
-
- **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
|
|
242
242
|
|
|
243
243
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
244
244
|
<!-- CHANGELOG:END -->
|
package/README.zh.md
CHANGED
|
@@ -207,11 +207,11 @@ CI 通过 `.github/workflows/npm-publish.yml` 发布 — GitHub 发布 release
|
|
|
207
207
|
## 更新日志 (Changelog)
|
|
208
208
|
|
|
209
209
|
<!-- CHANGELOG:START (auto-generated from CHANGELOG.md — run `bun run changelog:sync`) -->
|
|
210
|
+
- **[0.7.35]** (2026-07-04) — `jeo team` now runs every plan step through the SAME subagent execution core as the `task`/`subagent` tools, and gains gjc-style concurrent workers: a plan can mark a contiguous run of independent steps to execute in parallel, each isolated in its own git worktree, merged back in order.
|
|
211
|
+
- **[0.7.34]** (2026-07-04) — gajae-code 0.8.0/0.8.1 parity audit: closes a real cross-provider lone-surrogate JSON bug and makes turn-boundary compaction Esc-cancellable; the rest of gjc's 0.8.x release (Telegram daemon, ACP, cmux, coordinator MCP, tool-discovery-mode, an internal paged transcript viewport) is gjc-specific architecture jeo intentionally does not replicate, or was already-equivalent (confirmed by a 4-way parallel audit: ast_grep formatting, session-picker empty-filter clamping, PageUp/PageDown).
|
|
210
212
|
- **[0.7.33]** (2026-07-04) — Adds `browser` — headless Chromium automation (gjc `browser` parity) — completing the gjc tool-surface parity pass started in 0.7.32.
|
|
211
213
|
- **[0.7.32]** (2026-07-03) — gjc tool-surface parity — ast_grep/ast_edit, lsp/lsp_rename, debug, irc, job, and goal tools — plus a `launch.ts` slash-command refactor and four agent-loop guardrail hardenings (destructive-bash denylist, spawn-gate justification quality check, done-verification abuse guard, and a blind unanchored line-range-edit guard).
|
|
212
214
|
- **[0.7.31]** (2026-07-03) — Three-round speed & memory pass across streaming, memory retrieval, and the TUI render loop (O(n²) → O(n) in several hot paths), a deep-interview repeat-question loop guard, and gajae-code managed-tmux launch sizing parity.
|
|
213
|
-
- **[0.7.30]** (2026-07-02) — GPT/Codex OAuth calls work again, and the 30-minute turn budget now limits no-progress stalls instead of killing genuinely progressing autonomous runs.
|
|
214
|
-
- **[0.7.29]** (2026-07-02) — gajae-code safety-refusal retry parity: Anthropic/OAuth content-classifier refusals no longer end the turn with a fatal "declined to answer" message after the context-reset ladder.
|
|
215
215
|
|
|
216
216
|
See [CHANGELOG.md](CHANGELOG.md) for the full history.
|
|
217
217
|
<!-- CHANGELOG:END -->
|
package/package.json
CHANGED
package/src/agent/AGENTS.md
CHANGED
|
@@ -48,7 +48,7 @@ The core runtime loop, tool registry, session management, and state persistence
|
|
|
48
48
|
| `subagent-registry.ts` | Brief description of purpose |
|
|
49
49
|
| `subagent-tool.ts` | Brief description of purpose |
|
|
50
50
|
| `subagents.ts` | Brief description of purpose |
|
|
51
|
-
| `task-tool.ts` |
|
|
51
|
+
| `task-tool.ts` | `task` tool (single/fan-out/detached delegation) — exports `runSubagentOnce`, the single subagent execution core (history/runAgentLoop/contract validation/mutation audit/fenced report) shared with `jeo team`'s executor in `src/commands/team.ts` |
|
|
52
52
|
| `todo-tool.ts` | Brief description of purpose |
|
|
53
53
|
| `tokenizer.ts` | Brief description of purpose |
|
|
54
54
|
| `tool-output.ts` | Brief description of purpose |
|
package/src/agent/plan.ts
CHANGED
|
@@ -13,6 +13,11 @@ export const StepSchema = z.object({
|
|
|
13
13
|
name: z.string(),
|
|
14
14
|
/** Optional subagent role for this step (executor/planner/architect/critic). */
|
|
15
15
|
role: z.string().optional(),
|
|
16
|
+
/** Optional opt-in marker for a CONTIGUOUS run of steps `jeo team` may execute
|
|
17
|
+
* concurrently (git-worktree isolated) instead of strictly serially. Steps
|
|
18
|
+
* sharing the same non-empty value must be mutually consecutive — validated
|
|
19
|
+
* on `PlanSchema` below, since contiguity is a plan-wide property. */
|
|
20
|
+
parallel_group: z.string().optional(),
|
|
16
21
|
}).passthrough().superRefine((step, ctx) => {
|
|
17
22
|
for (const key of UNSUPPORTED_DEP_KEYS) {
|
|
18
23
|
if (key in step) {
|
|
@@ -27,7 +32,28 @@ export const StepSchema = z.object({
|
|
|
27
32
|
export const PlanSchema = z.object({
|
|
28
33
|
name: z.string().optional(),
|
|
29
34
|
steps: z.array(StepSchema).min(1),
|
|
30
|
-
}).passthrough()
|
|
35
|
+
}).passthrough().superRefine((plan, ctx) => {
|
|
36
|
+
// `parallel_group` steps must form a CONTIGUOUS run: once a group value is
|
|
37
|
+
// seen and then interrupted by a different (or absent) group, that value must
|
|
38
|
+
// never reappear later — `jeo team` dispatches a group as one concurrent batch
|
|
39
|
+
// located at a single position in the array, so a split group has no coherent
|
|
40
|
+
// execution position.
|
|
41
|
+
const closedGroups = new Set<string>();
|
|
42
|
+
let prevGroup: string | undefined;
|
|
43
|
+
for (const step of plan.steps) {
|
|
44
|
+
const group = step.parallel_group?.trim() || undefined;
|
|
45
|
+
if (group && group !== prevGroup && closedGroups.has(group)) {
|
|
46
|
+
ctx.addIssue({
|
|
47
|
+
code: z.ZodIssueCode.custom,
|
|
48
|
+
message: `plan step "${step.name}" reuses parallel_group "${group}" after a non-"${group}" step interrupted it — parallel_group steps must be contiguous.`,
|
|
49
|
+
});
|
|
50
|
+
}
|
|
51
|
+
if (prevGroup !== undefined && prevGroup !== group) {
|
|
52
|
+
closedGroups.add(prevGroup);
|
|
53
|
+
}
|
|
54
|
+
prevGroup = group;
|
|
55
|
+
}
|
|
56
|
+
});
|
|
31
57
|
|
|
32
58
|
/**
|
|
33
59
|
* Tolerate common planning-model deviations so a valid-enough plan still executes:
|
package/src/agent/task-tool.ts
CHANGED
|
@@ -187,13 +187,171 @@ export function fenceSubagentReport(detail: string): string {
|
|
|
187
187
|
].join("\n");
|
|
188
188
|
}
|
|
189
189
|
|
|
190
|
+
/**
|
|
191
|
+
* Build a `task` ToolHandler bound to a config + (optional) abort signal. The
|
|
192
|
+
* handler accepts `{ role?, task | prompt | assignment, context? }`.
|
|
193
|
+
*/
|
|
194
|
+
export interface RunSubagentOptions {
|
|
195
|
+
/** Resolves per-role model/step/thinking overrides; `defaultModel` is the fallback. */
|
|
196
|
+
config: Pick<Config, "defaultModel" | "subagents" | "thinkingLevel">;
|
|
197
|
+
/** Forwarded to the subagent's own runAgentLoop so Ctrl-C cancels nested work too. */
|
|
198
|
+
signal?: AbortSignal;
|
|
199
|
+
/** Optional live sink (e.g. plain-stream rendering of nested progress). */
|
|
200
|
+
onEvent?: (ev: TaskSubEvent) => void;
|
|
201
|
+
/** Mid-turn steering drain — see TaskToolOptions.steer for the exact contract. */
|
|
202
|
+
steer?: () => string[];
|
|
203
|
+
/** 1-based position within a fan-out/parallel batch, for event tagging. */
|
|
204
|
+
slot?: { index: number; total: number };
|
|
205
|
+
/** Pre-loaded project context to avoid re-scanning AGENTS.md per batch item. */
|
|
206
|
+
projectContext?: Awaited<ReturnType<typeof loadProjectContext>>;
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
/** `runSubagentOnce`'s result — a superset of `ToolResult` exposing the raw
|
|
210
|
+
* (unwrapped, unfenced) done reason and mutation-audit counters so a caller with
|
|
211
|
+
* its OWN pass/fail policy on top (e.g. `jeo team`'s role-gate-verdict parsing and
|
|
212
|
+
* `--strict-mutations`) doesn't have to re-derive them by parsing the formatted
|
|
213
|
+
* report text. `task`/`subagent` tool callers only ever need `success`/`output`/
|
|
214
|
+
* `error`, which stay structurally identical to plain `ToolResult`. */
|
|
215
|
+
export interface SubagentRunResult extends ToolResult {
|
|
216
|
+
/** Raw subagent done reason, before contract validation or fencing. */
|
|
217
|
+
doneReason: string;
|
|
218
|
+
/** True when the subagent actually called `done` (vs. exhausting its step
|
|
219
|
+
* budget or being force-stopped by an engine guard, e.g. a repeat-call loop). */
|
|
220
|
+
done: boolean;
|
|
221
|
+
/** Whether the done reason satisfied the role's required-marker contract. */
|
|
222
|
+
contractOk: boolean;
|
|
223
|
+
/** Required markers missing from the done reason, when `contractOk` is false. */
|
|
224
|
+
missingMarkers?: string[];
|
|
225
|
+
/** Successful write/edit/mkdir/delete calls observed (mutation audit evidence). */
|
|
226
|
+
fileMutations: number;
|
|
227
|
+
/** Successful bash calls observed (tracked apart — bash CAN mutate but isn't proof). */
|
|
228
|
+
bashRuns: number;
|
|
229
|
+
}
|
|
230
|
+
|
|
231
|
+
/**
|
|
232
|
+
* Run ONE subagent role to completion against `taskText`/`context` and format its
|
|
233
|
+
* report (fenced against prompt injection). This is the single execution core
|
|
234
|
+
* shared by the `task` tool's single/fan-out/detached paths AND `jeo team`'s plan
|
|
235
|
+
* executor (`src/commands/team.ts`) — there is exactly one way a jeo-code subagent
|
|
236
|
+
* runs, whether it was launched interactively, in a batch, detached, or as a team
|
|
237
|
+
* plan step.
|
|
238
|
+
*/
|
|
239
|
+
export async function runSubagentOnce(
|
|
240
|
+
role: ReturnType<typeof getSubagentRole> & {},
|
|
241
|
+
taskText: string,
|
|
242
|
+
context: string,
|
|
243
|
+
cwd: string,
|
|
244
|
+
opts: RunSubagentOptions,
|
|
245
|
+
): Promise<SubagentRunResult> {
|
|
246
|
+
const { steer, slot, projectContext: preloadedContext, signal } = opts;
|
|
247
|
+
// Tag every live event with its fan-out slot so a parent monitor can tell
|
|
248
|
+
// task 1 from task 3 when several same-role subagents stream concurrently.
|
|
249
|
+
const emit = (ev: TaskSubEvent) =>
|
|
250
|
+
opts.onEvent?.(slot ? { ...ev, index: slot.index, total: slot.total } : ev);
|
|
251
|
+
const model = resolveSubagentModel(role.id, opts.config);
|
|
252
|
+
const maxSteps = resolveSubagentMaxSteps(role.id, opts.config);
|
|
253
|
+
// gjc parity: a role may pin its own reasoning budget; absent = inherit the
|
|
254
|
+
// session/global thinking level (the "(inherit)" row in the picker).
|
|
255
|
+
const thinking = resolveSubagentThinking(role.id, opts.config) ?? opts.config.thinkingLevel;
|
|
256
|
+
const projectContext = preloadedContext ?? await loadProjectContext(cwd);
|
|
257
|
+
const memorySection = await memoryPromptSection(cwd, taskText);
|
|
258
|
+
const systemBase = withProjectContext(subagentSystemPrompt(role), projectContext);
|
|
259
|
+
const history: Message[] = [
|
|
260
|
+
{ role: "system", content: memorySection ? `${systemBase}\n\n${memorySection}` : systemBase },
|
|
261
|
+
{ role: "user", content: `${taskText}${context}` },
|
|
262
|
+
];
|
|
263
|
+
|
|
264
|
+
const trace: string[] = [];
|
|
265
|
+
let lastTarget = "";
|
|
266
|
+
let currentStep = 0;
|
|
267
|
+
// Round-8 (architect ref 7-Round7Workflow): count the subagent's SUCCESSFUL
|
|
268
|
+
// calls so the parent can audit a "Changed Files:" claim against observed
|
|
269
|
+
// reality. File-writing tools (write/edit/mkdir/delete) are tracked apart from
|
|
270
|
+
// bash: read-only bash (e.g. `bun test`) MUST NOT count as edit evidence, but
|
|
271
|
+
// bash CAN mutate, so the audit message distinguishes the two cases.
|
|
272
|
+
let fileMutations = 0;
|
|
273
|
+
let bashRuns = 0;
|
|
274
|
+
emit({ role: role.id, kind: "start", detail: taskText, maxSteps, model });
|
|
275
|
+
const result = await runAgentLoop(history, {
|
|
276
|
+
cwd,
|
|
277
|
+
model,
|
|
278
|
+
maxSteps,
|
|
279
|
+
maxTokens: resolveMaxOutputTokens(model, thinking),
|
|
280
|
+
// Per-run prompt-cache key: the subagent replays its own growing history each
|
|
281
|
+
// step, so a stable per-run key gets provider cache hits (gjc sub-session parity).
|
|
282
|
+
sessionKey: crypto.randomUUID(),
|
|
283
|
+
// Bounded delegation: a subagent's step contract stays exact — the parent
|
|
284
|
+
// owns any retry/extension decision, so the gjc retry flow is disabled here.
|
|
285
|
+
budget: { maxExtensions: 0 },
|
|
286
|
+
signal,
|
|
287
|
+
steer,
|
|
288
|
+
tools: subagentToolset(role),
|
|
289
|
+
events: {
|
|
290
|
+
onStep: n => { currentStep = n; },
|
|
291
|
+
onAssistant: (_raw, invocation) => {
|
|
292
|
+
if (invocation && invocation.tool && invocation.tool !== "done") {
|
|
293
|
+
lastTarget = toolTarget(invocation.tool, invocation.arguments);
|
|
294
|
+
trace.push(` step ${currentStep}/${maxSteps}: ${lastTarget}`);
|
|
295
|
+
emit({ role: role.id, kind: "step", detail: lastTarget, step: currentStep, maxSteps, model });
|
|
296
|
+
}
|
|
297
|
+
},
|
|
298
|
+
onToolResult: (tool, success, output) => {
|
|
299
|
+
if (success) {
|
|
300
|
+
if (tool === "write" || tool === "edit" || tool === "mkdir" || tool === "delete") fileMutations++;
|
|
301
|
+
else if (tool === "bash") bashRuns++;
|
|
302
|
+
}
|
|
303
|
+
const label = lastTarget || tool;
|
|
304
|
+
const summary = firstUsefulLine(output);
|
|
305
|
+
const suffix = summary ? ` — ${summary}` : "";
|
|
306
|
+
trace.push(` ${success ? "✓" : "✗"} ${label}${suffix}`);
|
|
307
|
+
emit({ role: role.id, kind: "tool", detail: label, success, summary, step: currentStep, maxSteps, model });
|
|
308
|
+
lastTarget = "";
|
|
309
|
+
},
|
|
310
|
+
// Retry notices (rate-limit backoff etc.) surface as live "step" beats so the
|
|
311
|
+
// parent's monitor shows WHY a subagent is pausing instead of going silent.
|
|
312
|
+
onNotice: msg => emit({ role: role.id, kind: "step", detail: msg, step: currentStep, maxSteps, model }),
|
|
313
|
+
// Mid-turn steering reached this subagent: surface it as a live beat so the
|
|
314
|
+
// parent's monitor shows the redirect instead of an unexplained behavior change.
|
|
315
|
+
onSteer: text => emit({ role: role.id, kind: "step", detail: `↳ steer: ${text}`, step: currentStep, maxSteps, model }),
|
|
316
|
+
},
|
|
317
|
+
});
|
|
318
|
+
const reason = result.doneReason?.trim() || `(subagent reached the ${result.steps}-step limit without signaling done)`;
|
|
319
|
+
const validation = validateSubagentDoneReason(role, reason);
|
|
320
|
+
const complete = result.done && validation.ok;
|
|
321
|
+
const detail = validation.ok ? reason : `${reason}\n\n[contract incomplete: missing ${validation.missing?.join(", ")}]`;
|
|
322
|
+
emit({ role: role.id, kind: "done", detail, success: complete, step: result.steps, maxSteps, model, tokens: result.usage ? { input: result.usage.inputTokens, output: result.usage.outputTokens } : undefined });
|
|
323
|
+
const tokNote = result.usage ? `, ${result.usage.inputTokens + result.usage.outputTokens} tok` : "";
|
|
324
|
+
const header = `[${role.title} subagent] ${complete ? "completed" : "stopped"} in ${result.steps} step(s) on ${model}${tokNote}.`;
|
|
325
|
+
const body = trace.length ? `\nSteps:\n${trace.join("\n")}` : "";
|
|
326
|
+
// Parent-side audit: a mutating role that "completed" without a successful file
|
|
327
|
+
// mutation (write/edit/mkdir/delete) likely changed nothing — flag the claim.
|
|
328
|
+
// bash is tracked separately: it CAN mutate, so an only-bash run downgrades to
|
|
329
|
+
// "verify independently" instead of the stronger UNVERIFIED.
|
|
330
|
+
const audit = complete && !role.readOnly && fileMutations === 0
|
|
331
|
+
? bashRuns === 0
|
|
332
|
+
? `\n[parent audit] No successful write/edit/bash was observed in this run — treat any "Changed Files:" claims above as UNVERIFIED.`
|
|
333
|
+
: `\n[parent audit] No successful write/edit was observed (only bash ran); bash may or may not have mutated files — verify any "Changed Files:" claims above independently.`
|
|
334
|
+
: "";
|
|
335
|
+
return {
|
|
336
|
+
success: complete,
|
|
337
|
+
output: `${header}${body}\n\nResult:\n${fenceSubagentReport(detail)}${audit}`,
|
|
338
|
+
doneReason: reason,
|
|
339
|
+
done: result.done,
|
|
340
|
+
contractOk: validation.ok,
|
|
341
|
+
missingMarkers: validation.ok ? undefined : validation.missing,
|
|
342
|
+
fileMutations,
|
|
343
|
+
bashRuns,
|
|
344
|
+
};
|
|
345
|
+
}
|
|
346
|
+
|
|
190
347
|
/**
|
|
191
348
|
* Build a `task` ToolHandler bound to a config + (optional) abort signal. The
|
|
192
349
|
* handler accepts `{ role?, task | prompt | assignment, context? }`.
|
|
193
350
|
*/
|
|
194
351
|
export function createTaskTool(opts: TaskToolOptions): ToolHandler {
|
|
195
|
-
/** Run ONE subagent
|
|
196
|
-
|
|
352
|
+
/** Run ONE subagent via the shared execution core, binding this tool instance's
|
|
353
|
+
* config/signal/onEvent (the original single-task path). */
|
|
354
|
+
const runOne = (
|
|
197
355
|
role: ReturnType<typeof getSubagentRole> & {},
|
|
198
356
|
taskText: string,
|
|
199
357
|
context: string,
|
|
@@ -206,98 +364,15 @@ export function createTaskTool(opts: TaskToolOptions): ToolHandler {
|
|
|
206
364
|
* is cancellable independently of the parent turn. */
|
|
207
365
|
signal?: AbortSignal;
|
|
208
366
|
} = {},
|
|
209
|
-
): Promise<ToolResult> =>
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
|
|
213
|
-
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
// gjc parity: a role may pin its own reasoning budget; absent = inherit the
|
|
218
|
-
// session/global thinking level (the "(inherit)" row in the picker).
|
|
219
|
-
const thinking = resolveSubagentThinking(role.id, opts.config) ?? opts.config.thinkingLevel;
|
|
220
|
-
const projectContext = preloadedContext ?? await loadProjectContext(cwd);
|
|
221
|
-
const memorySection = await memoryPromptSection(cwd, taskText);
|
|
222
|
-
const systemBase = withProjectContext(subagentSystemPrompt(role), projectContext);
|
|
223
|
-
const history: Message[] = [
|
|
224
|
-
{ role: "system", content: memorySection ? `${systemBase}\n\n${memorySection}` : systemBase },
|
|
225
|
-
{ role: "user", content: `${taskText}${context}` },
|
|
226
|
-
];
|
|
227
|
-
|
|
228
|
-
const trace: string[] = [];
|
|
229
|
-
let lastTarget = "";
|
|
230
|
-
let currentStep = 0;
|
|
231
|
-
// Round-8 (architect ref 7-Round7Workflow): count the subagent's SUCCESSFUL
|
|
232
|
-
// calls so the parent can audit a "Changed Files:" claim against observed
|
|
233
|
-
// reality. File-writing tools (write/edit/mkdir/delete) are tracked apart from
|
|
234
|
-
// bash: read-only bash (e.g. `bun test`) MUST NOT count as edit evidence, but
|
|
235
|
-
// bash CAN mutate, so the audit message distinguishes the two cases.
|
|
236
|
-
let fileMutations = 0;
|
|
237
|
-
let bashRuns = 0;
|
|
238
|
-
emit({ role: role.id, kind: "start", detail: taskText, maxSteps, model });
|
|
239
|
-
const result = await runAgentLoop(history, {
|
|
240
|
-
cwd,
|
|
241
|
-
model,
|
|
242
|
-
maxSteps,
|
|
243
|
-
maxTokens: resolveMaxOutputTokens(model, thinking),
|
|
244
|
-
// Per-run prompt-cache key: the subagent replays its own growing history each
|
|
245
|
-
// step, so a stable per-run key gets provider cache hits (gjc sub-session parity).
|
|
246
|
-
sessionKey: crypto.randomUUID(),
|
|
247
|
-
// Bounded delegation: a subagent's step contract stays exact — the parent
|
|
248
|
-
// owns any retry/extension decision, so the gjc retry flow is disabled here.
|
|
249
|
-
budget: { maxExtensions: 0 },
|
|
250
|
-
signal: signalOverride ?? opts.signal,
|
|
251
|
-
steer,
|
|
252
|
-
tools: subagentToolset(role),
|
|
253
|
-
events: {
|
|
254
|
-
onStep: n => { currentStep = n; },
|
|
255
|
-
onAssistant: (_raw, invocation) => {
|
|
256
|
-
if (invocation && invocation.tool && invocation.tool !== "done") {
|
|
257
|
-
lastTarget = toolTarget(invocation.tool, invocation.arguments);
|
|
258
|
-
trace.push(` step ${currentStep}/${maxSteps}: ${lastTarget}`);
|
|
259
|
-
emit({ role: role.id, kind: "step", detail: lastTarget, step: currentStep, maxSteps, model });
|
|
260
|
-
}
|
|
261
|
-
},
|
|
262
|
-
onToolResult: (tool, success, output) => {
|
|
263
|
-
if (success) {
|
|
264
|
-
if (tool === "write" || tool === "edit" || tool === "mkdir" || tool === "delete") fileMutations++;
|
|
265
|
-
else if (tool === "bash") bashRuns++;
|
|
266
|
-
}
|
|
267
|
-
const label = lastTarget || tool;
|
|
268
|
-
const summary = firstUsefulLine(output);
|
|
269
|
-
const suffix = summary ? ` — ${summary}` : "";
|
|
270
|
-
trace.push(` ${success ? "✓" : "✗"} ${label}${suffix}`);
|
|
271
|
-
emit({ role: role.id, kind: "tool", detail: label, success, summary, step: currentStep, maxSteps, model });
|
|
272
|
-
lastTarget = "";
|
|
273
|
-
},
|
|
274
|
-
// Retry notices (rate-limit backoff etc.) surface as live "step" beats so the
|
|
275
|
-
// parent's monitor shows WHY a subagent is pausing instead of going silent.
|
|
276
|
-
onNotice: msg => emit({ role: role.id, kind: "step", detail: msg, step: currentStep, maxSteps, model }),
|
|
277
|
-
// Mid-turn steering reached this subagent: surface it as a live beat so the
|
|
278
|
-
// parent's monitor shows the redirect instead of an unexplained behavior change.
|
|
279
|
-
onSteer: text => emit({ role: role.id, kind: "step", detail: `↳ steer: ${text}`, step: currentStep, maxSteps, model }),
|
|
280
|
-
},
|
|
367
|
+
): Promise<ToolResult> =>
|
|
368
|
+
runSubagentOnce(role, taskText, context, cwd, {
|
|
369
|
+
config: opts.config,
|
|
370
|
+
signal: extra.signal ?? opts.signal,
|
|
371
|
+
onEvent: opts.onEvent,
|
|
372
|
+
steer: extra.steer,
|
|
373
|
+
slot: extra.slot,
|
|
374
|
+
projectContext: extra.projectContext,
|
|
281
375
|
});
|
|
282
|
-
const reason = result.doneReason?.trim() || `(subagent reached the ${result.steps}-step limit without signaling done)`;
|
|
283
|
-
const validation = validateSubagentDoneReason(role, reason);
|
|
284
|
-
const complete = result.done && validation.ok;
|
|
285
|
-
const detail = validation.ok ? reason : `${reason}\n\n[contract incomplete: missing ${validation.missing?.join(", ")}]`;
|
|
286
|
-
emit({ role: role.id, kind: "done", detail, success: complete, step: result.steps, maxSteps, model, tokens: result.usage ? { input: result.usage.inputTokens, output: result.usage.outputTokens } : undefined });
|
|
287
|
-
const tokNote = result.usage ? `, ${result.usage.inputTokens + result.usage.outputTokens} tok` : "";
|
|
288
|
-
const header = `[${role.title} subagent] ${complete ? "completed" : "stopped"} in ${result.steps} step(s) on ${model}${tokNote}.`;
|
|
289
|
-
const body = trace.length ? `\nSteps:\n${trace.join("\n")}` : "";
|
|
290
|
-
// Parent-side audit: a mutating role that "completed" without a successful file
|
|
291
|
-
// mutation (write/edit/mkdir/delete) likely changed nothing — flag the claim.
|
|
292
|
-
// bash is tracked separately: it CAN mutate, so an only-bash run downgrades to
|
|
293
|
-
// "verify independently" instead of the stronger UNVERIFIED.
|
|
294
|
-
const audit = complete && !role.readOnly && fileMutations === 0
|
|
295
|
-
? bashRuns === 0
|
|
296
|
-
? `\n[parent audit] No successful write/edit/bash was observed in this run — treat any "Changed Files:" claims above as UNVERIFIED.`
|
|
297
|
-
: `\n[parent audit] No successful write/edit was observed (only bash ran); bash may or may not have mutated files — verify any "Changed Files:" claims above independently.`
|
|
298
|
-
: "";
|
|
299
|
-
return { success: complete, output: `${header}${body}\n\nResult:\n${fenceSubagentReport(detail)}${audit}` };
|
|
300
|
-
};
|
|
301
376
|
|
|
302
377
|
return async (args: Record<string, any>, cwd: string): Promise<ToolResult> => {
|
|
303
378
|
const roleArg = typeof args.role === "string" ? args.role.trim() : "";
|
|
@@ -4,6 +4,7 @@ import type { CallOptions, Message, ProviderAdapter } from "../types";
|
|
|
4
4
|
import { readSse } from "../sse";
|
|
5
5
|
import { ProviderHttpError, parseRetryAfter, parseRetryFromBody, providerHttpError } from "./errors";
|
|
6
6
|
import { serializeToolCalls, serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
|
|
7
|
+
import { sanitizeJsonStrings } from "../../util/sanitize-json";
|
|
7
8
|
|
|
8
9
|
const ANTHROPIC_URL = "https://api.anthropic.com/v1/messages";
|
|
9
10
|
|
|
@@ -306,7 +307,7 @@ export function anthropicPayload(
|
|
|
306
307
|
if (stream) payload.stream = true;
|
|
307
308
|
const system = anthropicSystemBlocks(systemPrompt, model, credential, payload, options.baseUrl);
|
|
308
309
|
if (system) payload.system = system;
|
|
309
|
-
return JSON.stringify(payload);
|
|
310
|
+
return JSON.stringify(sanitizeJsonStrings(payload));
|
|
310
311
|
}
|
|
311
312
|
|
|
312
313
|
export function anthropicRequest(
|
|
@@ -4,6 +4,7 @@ import type { CallOptions, Message, ProviderAdapter } from "../types";
|
|
|
4
4
|
import { readSse } from "../sse";
|
|
5
5
|
import { providerHttpError } from "./errors";
|
|
6
6
|
import { serializeToolCalls } from "../../agent/tool-schemas";
|
|
7
|
+
import { sanitizeJsonStrings } from "../../util/sanitize-json";
|
|
7
8
|
import { geminiThinkingConfig, getGeminiCliHeaders, type GeminiThinkingConfig } from "./gemini";
|
|
8
9
|
|
|
9
10
|
const ANTIGRAVITY_DAILY_ENDPOINT = "https://daily-cloudcode-pa.googleapis.com";
|
|
@@ -204,14 +205,14 @@ export function antigravityRequest(messages: Message[], options: CallOptions, cr
|
|
|
204
205
|
request.toolConfig = { functionCallingConfig: { mode: "AUTO" } };
|
|
205
206
|
}
|
|
206
207
|
|
|
207
|
-
const body = JSON.stringify({
|
|
208
|
+
const body = JSON.stringify(sanitizeJsonStrings({
|
|
208
209
|
project,
|
|
209
210
|
model,
|
|
210
211
|
request,
|
|
211
212
|
requestType: "agent",
|
|
212
213
|
userAgent: "antigravity",
|
|
213
214
|
requestId: `agent-${randomUUID()}`,
|
|
214
|
-
});
|
|
215
|
+
}));
|
|
215
216
|
return {
|
|
216
217
|
url: `${endpoint}/v1internal:streamGenerateContent?alt=sse`,
|
|
217
218
|
headers: {
|
|
@@ -4,6 +4,7 @@ import { readSse } from "../sse";
|
|
|
4
4
|
import { providerHttpError, fetchWithArtifactFailSafe } from "./errors";
|
|
5
5
|
import { jeoEnv } from "../../util/env";
|
|
6
6
|
import { serializeToolCalls } from "../../agent/tool-schemas";
|
|
7
|
+
import { sanitizeJsonStrings } from "../../util/sanitize-json";
|
|
7
8
|
|
|
8
9
|
/** Gemini 2.5+/latest models think by default and BILL thought tokens against
|
|
9
10
|
* `maxOutputTokens` — a small-budget call can burn its entire budget on thoughts
|
|
@@ -205,7 +206,7 @@ export function geminiRequest(messages: Message[], options: CallOptions, credent
|
|
|
205
206
|
const headers: Record<string, string> = oauth
|
|
206
207
|
? { "content-type": "application/json", authorization: `Bearer ${oauth}` }
|
|
207
208
|
: { "content-type": "application/json" };
|
|
208
|
-
return { url, headers, body: JSON.stringify(payload) };
|
|
209
|
+
return { url, headers, body: JSON.stringify(sanitizeJsonStrings(payload)) };
|
|
209
210
|
}
|
|
210
211
|
|
|
211
212
|
const CODE_ASSIST_ENDPOINT = "https://cloudcode-pa.googleapis.com";
|
|
@@ -236,7 +237,7 @@ export function geminiCliRequest(messages: Message[], options: CallOptions, acce
|
|
|
236
237
|
accept: "text/event-stream",
|
|
237
238
|
...getGeminiCliHeaders(geminiModel),
|
|
238
239
|
},
|
|
239
|
-
body: JSON.stringify({ project: projectId, model: geminiModel, request: payload }),
|
|
240
|
+
body: JSON.stringify(sanitizeJsonStrings({ project: projectId, model: geminiModel, request: payload })),
|
|
240
241
|
};
|
|
241
242
|
}
|
|
242
243
|
|
|
@@ -2,6 +2,7 @@ import type { CallOptions, Message, ProviderAdapter } from "../types";
|
|
|
2
2
|
import { readLines } from "../sse";
|
|
3
3
|
import { providerHttpError } from "./errors";
|
|
4
4
|
import { createThinkSplitter } from "../think-tags";
|
|
5
|
+
import { sanitizeJsonStrings } from "../../util/sanitize-json";
|
|
5
6
|
|
|
6
7
|
/**
|
|
7
8
|
* Resolve the Ollama base URL. `OLLAMA_HOST` is documented as a bare host:port
|
|
@@ -52,7 +53,7 @@ function ollamaRequest(messages: Message[], options: CallOptions, stream: boolea
|
|
|
52
53
|
};
|
|
53
54
|
if (options.jsonMode) payload.format = "json";
|
|
54
55
|
const base = normalizeOllamaBaseUrl(options.baseUrl);
|
|
55
|
-
return { url: `${base}/api/chat`, body: JSON.stringify(payload) };
|
|
56
|
+
return { url: `${base}/api/chat`, body: JSON.stringify(sanitizeJsonStrings(payload)) };
|
|
56
57
|
}
|
|
57
58
|
|
|
58
59
|
/** Round-5 #1: surface done_reason when a 200 carries no text (uniform contract). */
|
|
@@ -15,6 +15,7 @@ import type { CallOptions, Message } from "../types";
|
|
|
15
15
|
import { readSse } from "../sse";
|
|
16
16
|
import { providerHttpError, fetchWithArtifactFailSafe } from "./errors";
|
|
17
17
|
import { serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
|
|
18
|
+
import { sanitizeJsonStrings } from "../../util/sanitize-json";
|
|
18
19
|
import os from "node:os";
|
|
19
20
|
import pkg from "../../../package.json";
|
|
20
21
|
|
|
@@ -141,7 +142,7 @@ export function codexResponsesRequest(
|
|
|
141
142
|
return {
|
|
142
143
|
url: `${base}/responses`,
|
|
143
144
|
headers: { "content-type": "application/json", authorization: `Bearer ${token}`, accept: "text/event-stream", "user-agent": codexUserAgent() },
|
|
144
|
-
body: JSON.stringify(payload),
|
|
145
|
+
body: JSON.stringify(sanitizeJsonStrings(payload)),
|
|
145
146
|
};
|
|
146
147
|
}
|
|
147
148
|
const accountId = extractChatgptAccountId(token);
|
|
@@ -161,7 +162,7 @@ export function codexResponsesRequest(
|
|
|
161
162
|
headers.conversation_id = promptCacheKey;
|
|
162
163
|
headers["x-client-request-id"] = promptCacheKey;
|
|
163
164
|
}
|
|
164
|
-
return { url: CODEX_RESPONSES_URL, headers, body: JSON.stringify(payload) };
|
|
165
|
+
return { url: CODEX_RESPONSES_URL, headers, body: JSON.stringify(sanitizeJsonStrings(payload)) };
|
|
165
166
|
}
|
|
166
167
|
|
|
167
168
|
/** gjc parity (normalizeOpenAIResponsesPromptCacheKey): well-formed unicode, ≤64 chars
|
|
@@ -4,6 +4,7 @@ import { readSse } from "../sse";
|
|
|
4
4
|
import { providerHttpError } from "./errors";
|
|
5
5
|
import { codexResponsesCall, codexResponsesStream } from "./openai-responses";
|
|
6
6
|
import { serializeToolCalls, serializeAccumulatedToolCalls } from "../../agent/tool-schemas";
|
|
7
|
+
import { sanitizeJsonStrings } from "../../util/sanitize-json";
|
|
7
8
|
import { createThinkSplitter, stripLeakedReasoningTags } from "../think-tags";
|
|
8
9
|
|
|
9
10
|
/** True for OpenAI reasoning models (o-series + gpt-5+ family). Digit-count agnostic
|
|
@@ -96,7 +97,7 @@ export function openaiRequest(messages: Message[], options: CallOptions, credent
|
|
|
96
97
|
return {
|
|
97
98
|
url: `${base}/chat/completions`,
|
|
98
99
|
headers: { "content-type": "application/json", Authorization: `Bearer ${bearerFor(credential)}` },
|
|
99
|
-
body: JSON.stringify(payload),
|
|
100
|
+
body: JSON.stringify(sanitizeJsonStrings(payload)),
|
|
100
101
|
};
|
|
101
102
|
}
|
|
102
103
|
|
package/src/commands/AGENTS.md
CHANGED
|
@@ -28,7 +28,7 @@ Concrete implementations of `jeo` subcommands (e.g., launch, setup, team, ultrag
|
|
|
28
28
|
| `skills.ts` | Brief description of purpose |
|
|
29
29
|
| `state.ts` | Brief description of purpose |
|
|
30
30
|
| `status.ts` | Brief description of purpose |
|
|
31
|
-
| `team.ts` |
|
|
31
|
+
| `team.ts` | `jeo team` — executes an approved ralplan plan step-by-step via the SAME `runSubagentOnce` core the `task`/`subagent` tools use; a contiguous run of steps sharing a plan `parallel_group` runs concurrently in isolated git worktrees (`runParallelGroup`), merged back in order (gjc `team` concurrency parity) |
|
|
32
32
|
| `ultragoal.ts` | Brief description of purpose |
|
|
33
33
|
| `update.ts` | Brief description of purpose |
|
|
34
34
|
|
package/src/commands/launch.ts
CHANGED
|
@@ -880,10 +880,33 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
880
880
|
// Refresh injected OKF memory with THIS turn's query before the model call so
|
|
881
881
|
// failure-first ranking fires and same-session dead ends resurface.
|
|
882
882
|
await refreshSessionMemory(userInput);
|
|
883
|
-
|
|
884
|
-
|
|
885
|
-
|
|
883
|
+
// Esc-cancellable turn-boundary compaction preflight: a slow/hung summarization
|
|
884
|
+
// LLM call must not be un-cancellable just because it runs before the main
|
|
885
|
+
// per-turn abort harness (created below) exists yet. A short-lived harness scoped
|
|
886
|
+
// to just this call gives Esc/Ctrl+C the same abort path without wiring the full
|
|
887
|
+
// mid-turn steering machinery this early (which needs turn-scoped vars not
|
|
888
|
+
// constructed until after compaction/history mutation settles).
|
|
889
|
+
const compactHarness = createInFlightAbortHarness({
|
|
890
|
+
captureEsc: !!tui,
|
|
891
|
+
onAbortNotice: msg => {
|
|
892
|
+
if (tui) tui.events().onNotice?.(msg);
|
|
893
|
+
else console.log(msg);
|
|
894
|
+
},
|
|
895
|
+
onHardExit: () => {
|
|
896
|
+
if (tui) tui.finish("Cancelled.", { ok: false });
|
|
897
|
+
process.exit(130);
|
|
898
|
+
},
|
|
886
899
|
});
|
|
900
|
+
let compRes: Awaited<ReturnType<typeof maybeCompact>>;
|
|
901
|
+
try {
|
|
902
|
+
compRes = await maybeCompact(history, {
|
|
903
|
+
model: activeModel,
|
|
904
|
+
contextTokens,
|
|
905
|
+
signal: compactHarness.controller.signal,
|
|
906
|
+
});
|
|
907
|
+
} finally {
|
|
908
|
+
compactHarness.dispose();
|
|
909
|
+
}
|
|
887
910
|
if (compRes.error) {
|
|
888
911
|
throw new Error(compRes.error);
|
|
889
912
|
}
|
|
@@ -3587,6 +3610,26 @@ export async function runLaunchCommand(args: string[]): Promise<void> {
|
|
|
3587
3610
|
}
|
|
3588
3611
|
continue;
|
|
3589
3612
|
}
|
|
3613
|
+
if (input === "/wiki" || input.startsWith("/wiki ")) {
|
|
3614
|
+
const decision = decideWikiSlash(
|
|
3615
|
+
input,
|
|
3616
|
+
resolveWikiRoot(await readGlobalConfig()),
|
|
3617
|
+
!!jeoEnv("WIKI_ROOT"),
|
|
3618
|
+
);
|
|
3619
|
+
for (const line of decision.lines) console.log(line);
|
|
3620
|
+
if (decision.kind === "clear") {
|
|
3621
|
+
await saveConfigPatch(() => ({ wikiRoot: undefined }));
|
|
3622
|
+
delete process.env.JEO_WIKI_ROOT;
|
|
3623
|
+
sessionWikiRoot = undefined;
|
|
3624
|
+
await refreshSessionMemory("");
|
|
3625
|
+
} else if (decision.kind === "set") {
|
|
3626
|
+
await saveConfigPatch(() => ({ wikiRoot: decision.persistArg }));
|
|
3627
|
+
process.env.JEO_WIKI_ROOT = decision.root;
|
|
3628
|
+
sessionWikiRoot = decision.root;
|
|
3629
|
+
await refreshSessionMemory("");
|
|
3630
|
+
}
|
|
3631
|
+
continue;
|
|
3632
|
+
}
|
|
3590
3633
|
if (input === "/evolve") {
|
|
3591
3634
|
await runEvolveSimulation(animateAsciiArt);
|
|
3592
3635
|
continue;
|
package/src/commands/ralplan.ts
CHANGED
|
@@ -142,7 +142,17 @@ export async function runRalplanEngine(opts: RalplanEngineOptions = {}): Promise
|
|
|
142
142
|
` - name: "<imperative task, e.g. Implement reverse() in src/reverse.ts>"\n` +
|
|
143
143
|
` role: executor # one of: executor | planner | architect | critic\n` +
|
|
144
144
|
` target: "<primary file path>"\n` +
|
|
145
|
-
`
|
|
145
|
+
` parallel_group: "<optional short id, e.g. g1>"\n` +
|
|
146
|
+
`Provide 3-8 concrete, ordered steps. Output ONLY the YAML.\n\n` +
|
|
147
|
+
`parallel_group is OPTIONAL and OFF by default — omit it unless you are certain. ` +
|
|
148
|
+
`'jeo team' executes steps strictly in array order, EXCEPT a contiguous (adjacent, ` +
|
|
149
|
+
`no other step in between) run of steps sharing the SAME non-empty parallel_group value, ` +
|
|
150
|
+
`which it runs CONCURRENTLY in isolated git worktrees and merges back. Only mark steps ` +
|
|
151
|
+
`parallel_group when they are TRULY independent: each touches DISJOINT files with no step ` +
|
|
152
|
+
`reading or building on another's output. Never mark two steps parallel if one depends on, ` +
|
|
153
|
+
`reads, or extends a file another writes. When unsure, leave parallel_group out — a plain ` +
|
|
154
|
+
`serial step is always correct; a wrongly-parallel step risks a merge conflict that halts ` +
|
|
155
|
+
`execution.`;
|
|
146
156
|
|
|
147
157
|
const PLANNER = `You are the PLANNER. From the crystallized spec, sequence the work into a logical, outcome-based progression of concrete, ordered tasks.\n` + SCHEMA_SPEC;
|
|
148
158
|
const ARCHITECT = `You are the ARCHITECT. Review the Planner's draft for technical feasibility, correct file targets, directory structure, and any missing setup/wiring/test steps. Return an improved plan (same shape).\n` + SCHEMA_SPEC;
|
package/src/commands/team.ts
CHANGED
|
@@ -1,4 +1,5 @@
|
|
|
1
1
|
import * as fs from "node:fs/promises";
|
|
2
|
+
import * as path from "node:path";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import chalk from "chalk";
|
|
4
5
|
import { PlanSchema, normalizePlanShape, parseYaml } from "../agent/plan";
|
|
@@ -8,24 +9,21 @@ import {
|
|
|
8
9
|
writeWorkflowState,
|
|
9
10
|
acquireWorkflowRunLock,
|
|
10
11
|
type WorkflowState,
|
|
12
|
+
type Config,
|
|
11
13
|
} from "../agent/state";
|
|
12
|
-
import {
|
|
13
|
-
import {
|
|
14
|
-
import {
|
|
15
|
-
import { gitDirtyCount } from "./launch/tmux";
|
|
14
|
+
import { runSubagentOnce, type SubagentRunResult } from "../agent/task-tool";
|
|
15
|
+
import { SubagentRegistry } from "../agent/subagent-registry";
|
|
16
|
+
import { gitDirtyCount, resolveWorktree } from "./launch/tmux";
|
|
16
17
|
import { readGlobalConfig } from "../agent/state";
|
|
17
18
|
import {
|
|
18
19
|
defaultSubagentRole,
|
|
19
20
|
getSubagentRole,
|
|
20
21
|
resolveSubagentModel,
|
|
21
22
|
resolveSubagentMaxSteps,
|
|
22
|
-
subagentSystemPrompt,
|
|
23
|
-
subagentToolset,
|
|
24
23
|
subagentRoleIds,
|
|
25
|
-
|
|
24
|
+
type SubagentRole,
|
|
26
25
|
} from "../agent/subagents";
|
|
27
|
-
import
|
|
28
|
-
import { loadProjectContext, withProjectContext } from "../agent/context-files";
|
|
26
|
+
import { loadProjectContext } from "../agent/context-files";
|
|
29
27
|
import { categoryBadge } from "../tui/components/category-index";
|
|
30
28
|
|
|
31
29
|
export type RalphStreamKind = "step" | "complete" | "error" | "warn";
|
|
@@ -343,12 +341,65 @@ export async function runTeamEngine(opts: TeamEngineOptions = {}): Promise<{ ok:
|
|
|
343
341
|
return { ok: false, reason: "aborted" };
|
|
344
342
|
}
|
|
345
343
|
|
|
346
|
-
const currentTask = teamState.pending_tasks[0];
|
|
347
|
-
log(`\n${categoryBadge("progress")} Current task: "${currentTask}"`);
|
|
348
344
|
const activeIndex = activeStepIndex(tasks.length, teamState.pending_tasks);
|
|
349
345
|
// Guide printed once above; per-task lines convey progress. No per-iteration
|
|
350
346
|
// reprint — it was O(N²) lines, climbing the embedded-terminal renderer's CPU.
|
|
351
347
|
|
|
348
|
+
// Look ahead from the current position: if this step's `parallel_group`
|
|
349
|
+
// extends into one or more of the NEXT pending steps (the group's full
|
|
350
|
+
// contiguous run is, by construction, always still all-pending here —
|
|
351
|
+
// `activeIndex` is derived from how many tasks remain), dispatch the WHOLE
|
|
352
|
+
// group concurrently. A group of size 1 (no group, or a lone member) falls
|
|
353
|
+
// straight through to the existing serial path, unchanged.
|
|
354
|
+
const groupVal = parsed.data.steps[activeIndex]?.parallel_group?.trim() || undefined;
|
|
355
|
+
const groupIndices = [activeIndex];
|
|
356
|
+
if (groupVal) {
|
|
357
|
+
let i = activeIndex + 1;
|
|
358
|
+
while (i < tasks.length && (parsed.data.steps[i]?.parallel_group?.trim() || undefined) === groupVal) {
|
|
359
|
+
groupIndices.push(i);
|
|
360
|
+
i++;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
if (groupIndices.length > 1) {
|
|
365
|
+
const groupSteps = groupIndices.map(i => ({ index: i, name: tasks[i]!, roleId: roleByIndex[i] }));
|
|
366
|
+
log(`\n${categoryBadge("progress")} Current parallel group "${groupVal}": ${groupSteps.map(s => `"${s.name}"`).join(", ")}`);
|
|
367
|
+
if (opts.onProgress) {
|
|
368
|
+
opts.onProgress({ skill: "team", phase: "executing", detail: `Current parallel group: ${groupSteps.map(s => s.name).join(", ")}` });
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
const groupResult = await runParallelGroup(groupSteps, {
|
|
372
|
+
cwd,
|
|
373
|
+
teamCfg,
|
|
374
|
+
tasks,
|
|
375
|
+
completed: teamState.completed_tasks ?? [],
|
|
376
|
+
strictMutations: opts.strictMutations ?? false,
|
|
377
|
+
log,
|
|
378
|
+
slug: planState.slug ?? teamState.slug ?? "team",
|
|
379
|
+
});
|
|
380
|
+
|
|
381
|
+
if (opts.signal?.aborted) {
|
|
382
|
+
return { ok: false, reason: "aborted" };
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
if (groupResult.ok) {
|
|
386
|
+
teamState.completed_tasks = [...(teamState.completed_tasks ?? []), ...groupSteps.map(s => s.name)];
|
|
387
|
+
teamState.pending_tasks = teamState.pending_tasks.slice(groupSteps.length);
|
|
388
|
+
await writeWorkflowState("team", teamState, cwd);
|
|
389
|
+
log(`${categoryBadge("done")} Completed parallel group: ${groupSteps.map(s => `"${s.name}"`).join(", ")}`);
|
|
390
|
+
} else {
|
|
391
|
+
teamState.current_phase = "failed";
|
|
392
|
+
teamState.failed_task = groupResult.failedTaskName;
|
|
393
|
+
await writeWorkflowState("team", teamState, cwd);
|
|
394
|
+
log(`${categoryBadge("error")} Failed on parallel group step: "${groupResult.failedTaskName}". Halting execution.`);
|
|
395
|
+
return { ok: false, reason: `Failed on task: "${groupResult.failedTaskName}"` };
|
|
396
|
+
}
|
|
397
|
+
continue;
|
|
398
|
+
}
|
|
399
|
+
|
|
400
|
+
const currentTask = teamState.pending_tasks[0];
|
|
401
|
+
log(`\n${categoryBadge("progress")} Current task: "${currentTask}"`);
|
|
402
|
+
|
|
352
403
|
if (opts.onProgress) {
|
|
353
404
|
opts.onProgress({ skill: "team", phase: "executing", detail: `Current task: ${currentTask}` });
|
|
354
405
|
}
|
|
@@ -417,89 +468,68 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
|
|
|
417
468
|
const maxSteps = resolveSubagentMaxSteps(role.id, config);
|
|
418
469
|
log(` └─ Subagent: ${role.title} · model ${model} · ≤${maxSteps} steps`);
|
|
419
470
|
|
|
420
|
-
const contextTokens = catalogMetadata(model)?.contextTokens;
|
|
421
|
-
|
|
422
471
|
const projectContext = await loadProjectContext(ctx.cwd);
|
|
423
|
-
const history: Message[] = [
|
|
424
|
-
{ role: "system", content: withProjectContext(subagentSystemPrompt(role), projectContext) },
|
|
425
|
-
{ role: "user", content: buildRalphSubagentPrompt(ctx) },
|
|
426
|
-
];
|
|
427
472
|
|
|
428
|
-
|
|
429
|
-
|
|
430
|
-
|
|
431
|
-
|
|
432
|
-
|
|
433
|
-
|
|
434
|
-
|
|
435
|
-
|
|
436
|
-
|
|
437
|
-
|
|
438
|
-
|
|
439
|
-
|
|
440
|
-
|
|
441
|
-
|
|
442
|
-
|
|
443
|
-
|
|
444
|
-
// orchestrator owns retries, so the gjc step-extension flow is disabled here.
|
|
445
|
-
budget: { maxExtensions: 0 },
|
|
446
|
-
tools: subagentToolset(role),
|
|
447
|
-
events: {
|
|
448
|
-
onAssistant: (_raw, invocation) => {
|
|
449
|
-
if (!invocation) {
|
|
450
|
-
log(formatRalphStreamEvent("error", "invalid tool-call json; retrying", renderOpts));
|
|
451
|
-
} else if (invocation.tool !== "done") {
|
|
452
|
-
log(formatRalphStreamEvent("step", `tool ${invocation.tool} requested`, renderOpts));
|
|
453
|
-
}
|
|
454
|
-
},
|
|
455
|
-
onStep: async step => {
|
|
456
|
-
log(formatRalphStreamEvent("step", `${role.title} thinking ${step}/${maxSteps}`, renderOpts));
|
|
457
|
-
try {
|
|
458
|
-
await maybeCompact(history, { model, contextTokens });
|
|
459
|
-
} catch (err) {
|
|
460
|
-
// LLM summary failure does not halt team
|
|
461
|
-
}
|
|
462
|
-
},
|
|
463
|
-
onToolResult: (tool, ok) => {
|
|
464
|
-
if (ok) {
|
|
465
|
-
if (tool === "write" || tool === "edit" || tool === "mkdir" || tool === "delete") fileMutations++;
|
|
466
|
-
else if (tool === "bash") bashRuns++;
|
|
467
|
-
}
|
|
468
|
-
log(formatRalphStreamEvent(ok ? "complete" : "error", `tool ${tool}`, renderOpts));
|
|
469
|
-
},
|
|
470
|
-
onNotice: msg => log(formatRalphStreamEvent("step", msg, renderOpts)),
|
|
473
|
+
// `jeo team` now runs every step through the SAME execution core as the `task`/
|
|
474
|
+
// `subagent` tools (`runSubagentOnce`) — one way a jeo-code subagent runs,
|
|
475
|
+
// whether launched interactively, in a fan-out batch, detached, or as a team
|
|
476
|
+
// plan step. This gets team plan steps the same fenced-report protection
|
|
477
|
+
// (prompt-injection-safe) and done-reason contract validation the interactive
|
|
478
|
+
// tools already had, instead of a second hand-rolled copy of that logic.
|
|
479
|
+
const result = await runSubagentOnce(role, buildRalphSubagentPrompt(ctx), "", ctx.cwd, {
|
|
480
|
+
config,
|
|
481
|
+
projectContext,
|
|
482
|
+
onEvent: ev => {
|
|
483
|
+
if (ev.kind === "step") {
|
|
484
|
+
log(formatRalphStreamEvent("step", ev.detail ?? `${role.title} thinking ${ev.step ?? "?"}/${ev.maxSteps ?? maxSteps}`, renderOpts));
|
|
485
|
+
} else if (ev.kind === "tool") {
|
|
486
|
+
const suffix = ev.summary ? ` — ${ev.summary}` : "";
|
|
487
|
+
log(formatRalphStreamEvent(ev.success ? "complete" : "error", `tool ${ev.detail}${suffix}`, renderOpts));
|
|
488
|
+
}
|
|
471
489
|
},
|
|
472
490
|
});
|
|
473
491
|
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
log(formatRalphStreamEvent("error", reason, renderOpts));
|
|
477
|
-
return false;
|
|
478
|
-
}
|
|
492
|
+
return evaluateSubagentResult(result, role, ctx.strictMutations ?? false, log, renderOpts);
|
|
493
|
+
}
|
|
479
494
|
|
|
480
|
-
|
|
481
|
-
|
|
482
|
-
|
|
495
|
+
/**
|
|
496
|
+
* Shared pass/fail verdict for ONE subagent's `SubagentRunResult`: contract
|
|
497
|
+
* failure, role-gate rejection (architect BLOCK / critic REQUEST CHANGES), and
|
|
498
|
+
* `--strict-mutations` hard-fail. Used by BOTH the serial path
|
|
499
|
+
* (`executeTaskWithAgent`) and the parallel-group path (`runParallelGroup`) so
|
|
500
|
+
* the two never diverge on what counts as a passing step.
|
|
501
|
+
*/
|
|
502
|
+
function evaluateSubagentResult(
|
|
503
|
+
result: SubagentRunResult,
|
|
504
|
+
role: SubagentRole,
|
|
505
|
+
strictMutations: boolean,
|
|
506
|
+
log: (line: string) => void,
|
|
507
|
+
renderOpts: RalphRenderOptions,
|
|
508
|
+
): boolean {
|
|
509
|
+
if (!result.success) {
|
|
510
|
+
log(formatRalphStreamEvent("error", result.error || result.output, renderOpts));
|
|
483
511
|
return false;
|
|
484
512
|
}
|
|
485
513
|
|
|
486
|
-
|
|
514
|
+
// Role gate verdict (architect BLOCK / critic REQUEST CHANGES) is parsed from the
|
|
515
|
+
// subagent's RAW done reason, not the fenced/wrapped report text around it.
|
|
516
|
+
const gate = parseRoleGateVerdict(role.id, result.doneReason);
|
|
487
517
|
if (!gate.ok) {
|
|
488
518
|
log(formatRalphStreamEvent("error", gate.message ?? `${role.title} blocked execution`, renderOpts));
|
|
489
519
|
return false;
|
|
490
520
|
}
|
|
491
521
|
|
|
492
|
-
|
|
493
|
-
|
|
494
|
-
|
|
495
|
-
|
|
496
|
-
const msg = bashRuns === 0
|
|
522
|
+
// Round-8: a mutating role finished without a successful file mutation — the
|
|
523
|
+
// task may be legitimately read-only, but its "Changed Files:" claim is
|
|
524
|
+
// unverified. bash is tracked apart: an only-bash run MIGHT have mutated.
|
|
525
|
+
if (!role.readOnly && result.fileMutations === 0) {
|
|
526
|
+
const msg = result.bashRuns === 0
|
|
497
527
|
? `${role.title} completed WITHOUT any successful write/edit/bash — treat its changed-files claim as unverified.`
|
|
498
528
|
: `${role.title} completed with only bash (no write/edit) — verify its changed-files claim independently.`;
|
|
499
529
|
// Round-11: under --strict-mutations, a mutating role that took NO action at
|
|
500
530
|
// all (no write/edit/bash) is a hard failure — an empty run must not pass as
|
|
501
531
|
// a completed task. bash-only stays advisory to avoid penalizing shell edits.
|
|
502
|
-
const hardFail =
|
|
532
|
+
const hardFail = strictMutations && result.bashRuns === 0;
|
|
503
533
|
// Round-12: separate the tones so a passing advisory run doesn't masquerade
|
|
504
534
|
// as a stream:error — only a real hard-fail is red; an advisory note is warn.
|
|
505
535
|
log(formatRalphStreamEvent(hardFail ? "error" : "warn", msg, renderOpts));
|
|
@@ -507,12 +537,169 @@ async function executeTaskWithAgent(ctx: RalphSubagentPromptContext & { cwd: str
|
|
|
507
537
|
return false;
|
|
508
538
|
}
|
|
509
539
|
}
|
|
510
|
-
|
|
511
|
-
//
|
|
512
|
-
//
|
|
540
|
+
|
|
541
|
+
// Surface the subagent's full report (header + step trace + fenced reason +
|
|
542
|
+
// mutation audit) — not just a "finished" status.
|
|
513
543
|
log(formatRalphStreamEvent("complete", `${role.title} finished task`, renderOpts));
|
|
514
544
|
log(`\n${role.title} report:`);
|
|
515
|
-
log(
|
|
545
|
+
log(result.output); // log() already splits multi-line strings across the io.output sink
|
|
516
546
|
|
|
517
547
|
return true;
|
|
518
548
|
}
|
|
549
|
+
|
|
550
|
+
/** One step of a dispatched parallel group, resolved to its plan position + role. */
|
|
551
|
+
interface ParallelGroupStep {
|
|
552
|
+
index: number;
|
|
553
|
+
name: string;
|
|
554
|
+
roleId?: string;
|
|
555
|
+
}
|
|
556
|
+
|
|
557
|
+
/** Per-step worktree + branch resolved for a dispatched parallel group. */
|
|
558
|
+
interface ParallelWorktree {
|
|
559
|
+
path: string;
|
|
560
|
+
branch: string;
|
|
561
|
+
}
|
|
562
|
+
|
|
563
|
+
function runGitSync(cwd: string, args: string[]): { ok: boolean; stdout: string; stderr: string } {
|
|
564
|
+
const res = Bun.spawnSync(["git", ...args], { cwd, stdout: "pipe", stderr: "pipe" });
|
|
565
|
+
return { ok: res.exitCode === 0, stdout: res.stdout.toString(), stderr: res.stderr.toString() };
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
/**
|
|
569
|
+
* Execute a CONTIGUOUS run of `parallel_group`-marked plan steps concurrently,
|
|
570
|
+
* each in its own git worktree via `resolveWorktree` + the SAME
|
|
571
|
+
* `SubagentRegistry`/`runSubagentOnce` core the serial path and the `task`/
|
|
572
|
+
* `subagent` tools use. On full success, each worker's committed branch is
|
|
573
|
+
* merged back into `cwd`'s current branch IN ARRAY ORDER; any failure or merge
|
|
574
|
+
* conflict stops processing immediately and leaves the remaining
|
|
575
|
+
* worktrees/branches in place for manual inspection — nothing is ever silently
|
|
576
|
+
* resolved or discarded.
|
|
577
|
+
*/
|
|
578
|
+
async function runParallelGroup(
|
|
579
|
+
group: ParallelGroupStep[],
|
|
580
|
+
ctx: {
|
|
581
|
+
cwd: string;
|
|
582
|
+
teamCfg: Config;
|
|
583
|
+
tasks: string[];
|
|
584
|
+
completed: readonly string[];
|
|
585
|
+
strictMutations: boolean;
|
|
586
|
+
log: (line: string) => void;
|
|
587
|
+
slug: string;
|
|
588
|
+
},
|
|
589
|
+
): Promise<{ ok: true } | { ok: false; failedTaskName: string }> {
|
|
590
|
+
const { cwd, teamCfg, tasks, completed, strictMutations, log, slug } = ctx;
|
|
591
|
+
const renderOpts: RalphRenderOptions = { color: !!process.stdout.isTTY, indexed: true };
|
|
592
|
+
|
|
593
|
+
const registry = new SubagentRegistry();
|
|
594
|
+
const resultsByIndex = new Map<number, SubagentRunResult>();
|
|
595
|
+
const idByIndex = new Map<number, string>();
|
|
596
|
+
const roleByStepIndex = new Map<number, SubagentRole>();
|
|
597
|
+
const worktreeByIndex = new Map<number, ParallelWorktree>();
|
|
598
|
+
|
|
599
|
+
log(`\n${categoryBadge("progress")} Dispatching parallel group of ${group.length} steps concurrently: ${group.map(s => `"${s.name}"`).join(", ")}`);
|
|
600
|
+
|
|
601
|
+
for (const step of group) {
|
|
602
|
+
const worktreePath = path.join(cwd, ".jeo", "team-worktrees", `${slug}-${step.index}`);
|
|
603
|
+
const resolvedPath = resolveWorktree(cwd, worktreePath);
|
|
604
|
+
const branchRes = runGitSync(resolvedPath, ["rev-parse", "--abbrev-ref", "HEAD"]);
|
|
605
|
+
const branch = branchRes.stdout.trim();
|
|
606
|
+
worktreeByIndex.set(step.index, { path: resolvedPath, branch });
|
|
607
|
+
|
|
608
|
+
const role = getSubagentRole(step.roleId, teamCfg) ?? defaultSubagentRole();
|
|
609
|
+
roleByStepIndex.set(step.index, role);
|
|
610
|
+
const model = resolveSubagentModel(role.id, teamCfg);
|
|
611
|
+
const maxSteps = resolveSubagentMaxSteps(role.id, teamCfg);
|
|
612
|
+
log(` └─ [${step.name}] Subagent: ${role.title} · model ${model} · ≤${maxSteps} steps · worktree ${resolvedPath} (branch ${branch})`);
|
|
613
|
+
|
|
614
|
+
// Loaded PER WORKTREE (its own file tree) — never shared across concurrent workers.
|
|
615
|
+
const projectContext = await loadProjectContext(resolvedPath);
|
|
616
|
+
const promptCtx: RalphSubagentPromptContext = { task: step.name, tasks, activeIndex: step.index, completed };
|
|
617
|
+
const taskText = buildRalphSubagentPrompt(promptCtx);
|
|
618
|
+
|
|
619
|
+
const record = registry.launch(role.id, step.name, async (signal) => {
|
|
620
|
+
const result = await runSubagentOnce(role, taskText, "", resolvedPath, {
|
|
621
|
+
config: teamCfg,
|
|
622
|
+
signal,
|
|
623
|
+
projectContext,
|
|
624
|
+
onEvent: ev => {
|
|
625
|
+
if (ev.kind === "step") {
|
|
626
|
+
log(formatRalphStreamEvent("step", `[${step.name}] ${ev.detail ?? `${role.title} thinking ${ev.step ?? "?"}/${ev.maxSteps ?? maxSteps}`}`, renderOpts));
|
|
627
|
+
} else if (ev.kind === "tool") {
|
|
628
|
+
const suffix = ev.summary ? ` — ${ev.summary}` : "";
|
|
629
|
+
log(formatRalphStreamEvent(ev.success ? "complete" : "error", `[${step.name}] tool ${ev.detail}${suffix}`, renderOpts));
|
|
630
|
+
}
|
|
631
|
+
},
|
|
632
|
+
});
|
|
633
|
+
// Capture the FULL SubagentRunResult (doneReason/fileMutations/etc.) before
|
|
634
|
+
// collapsing it to the plain ToolResult shape SubagentRegistry stores.
|
|
635
|
+
resultsByIndex.set(step.index, result);
|
|
636
|
+
return { success: result.success, output: result.output, error: result.error };
|
|
637
|
+
});
|
|
638
|
+
idByIndex.set(step.index, record.id);
|
|
639
|
+
}
|
|
640
|
+
|
|
641
|
+
// No timeout — team runs can take as long as needed.
|
|
642
|
+
await registry.awaitIds([...idByIndex.values()]);
|
|
643
|
+
|
|
644
|
+
// Evaluate + commit each successful worktree, IN ARRAY ORDER (not completion
|
|
645
|
+
// order). Any failure stops the whole group immediately: no merges happen,
|
|
646
|
+
// and every worktree/branch is left exactly as it is for inspection.
|
|
647
|
+
for (const step of group) {
|
|
648
|
+
const result = resultsByIndex.get(step.index);
|
|
649
|
+
const role = roleByStepIndex.get(step.index)!;
|
|
650
|
+
const wt = worktreeByIndex.get(step.index)!;
|
|
651
|
+
if (!result) {
|
|
652
|
+
log(formatRalphStreamEvent("error", `[${step.name}] subagent produced no result`, renderOpts));
|
|
653
|
+
return { ok: false, failedTaskName: step.name };
|
|
654
|
+
}
|
|
655
|
+
const success = evaluateSubagentResult(result, role, strictMutations, log, renderOpts);
|
|
656
|
+
if (!success) {
|
|
657
|
+
log(`${categoryBadge("error")} Parallel group step "${step.name}" failed — its worktree (${wt.path}) and the OTHER group worktrees are left in place for inspection; nothing was merged.`);
|
|
658
|
+
return { ok: false, failedTaskName: step.name };
|
|
659
|
+
}
|
|
660
|
+
// An empty diff (read-only role, or a no-op) needs no commit — skip it for
|
|
661
|
+
// the merge step below without treating it as an error.
|
|
662
|
+
if (gitDirtyCount(wt.path)) {
|
|
663
|
+
const add = runGitSync(wt.path, ["add", "-A"]);
|
|
664
|
+
if (!add.ok) {
|
|
665
|
+
log(`${categoryBadge("error")} Failed to stage changes in worktree ${wt.path} for step "${step.name}": ${add.stderr.trim()}`);
|
|
666
|
+
return { ok: false, failedTaskName: step.name };
|
|
667
|
+
}
|
|
668
|
+
const commit = runGitSync(wt.path, ["commit", "-m", step.name]);
|
|
669
|
+
if (!commit.ok) {
|
|
670
|
+
log(`${categoryBadge("error")} Failed to commit changes in worktree ${wt.path} for step "${step.name}": ${commit.stderr.trim()}`);
|
|
671
|
+
return { ok: false, failedTaskName: step.name };
|
|
672
|
+
}
|
|
673
|
+
}
|
|
674
|
+
}
|
|
675
|
+
|
|
676
|
+
// All group steps succeeded — merge each worker branch back, IN ARRAY ORDER.
|
|
677
|
+
// A conflict aborts the merge immediately (never auto-resolved) and stops the
|
|
678
|
+
// rest of the group's merges.
|
|
679
|
+
for (const step of group) {
|
|
680
|
+
const wt = worktreeByIndex.get(step.index)!;
|
|
681
|
+
const merge = runGitSync(cwd, ["merge", "--no-ff", wt.branch, "-m", `merge: ${step.name}`]);
|
|
682
|
+
if (!merge.ok) {
|
|
683
|
+
runGitSync(cwd, ["merge", "--abort"]);
|
|
684
|
+
log(
|
|
685
|
+
`${categoryBadge("error")} Merge conflict merging step "${step.name}"'s branch "${wt.branch}" into the current branch — ` +
|
|
686
|
+
`the merge was aborted automatically (no changes were auto-resolved). Resolve manually: ` +
|
|
687
|
+
`cd ${wt.path} to inspect the change, then run 'git merge ${wt.branch}' yourself from ${cwd}.\n${(merge.stderr + merge.stdout).trim()}`,
|
|
688
|
+
);
|
|
689
|
+
return { ok: false, failedTaskName: step.name };
|
|
690
|
+
}
|
|
691
|
+
log(`${categoryBadge("done")} Merged step "${step.name}" (branch ${wt.branch}) into the current branch.`);
|
|
692
|
+
}
|
|
693
|
+
|
|
694
|
+
// Fully successful group — clean up worktrees best-effort (a removal failure,
|
|
695
|
+
// e.g. stray untracked files, is a warning, never a hard failure of the run).
|
|
696
|
+
for (const step of group) {
|
|
697
|
+
const wt = worktreeByIndex.get(step.index)!;
|
|
698
|
+
const removal = runGitSync(cwd, ["worktree", "remove", wt.path]);
|
|
699
|
+
if (!removal.ok) {
|
|
700
|
+
log(`[WARN] Could not remove worktree ${wt.path} for step "${step.name}": ${removal.stderr.trim()}`);
|
|
701
|
+
}
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
return { ok: true };
|
|
705
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Lone-surrogate sanitization for provider request bodies (gjc 0.8.1 parity).
|
|
3
|
+
*
|
|
4
|
+
* Tool-call arguments, tool-result output, and message content are embedded raw into
|
|
5
|
+
* provider request bodies before `JSON.stringify`. A truncated multi-byte emoji (or
|
|
6
|
+
* model-echoed text) can leave an unpaired UTF-16 surrogate in a string; `JSON.stringify`
|
|
7
|
+
* happily emits it as a lone `\ud8xx`/`\udcxx` escape, which strict provider JSON parsers
|
|
8
|
+
* reject (e.g. Anthropic 400 "no low surrogate in string"). `sanitizeJsonStrings` walks an
|
|
9
|
+
* arbitrary JS value and replaces every string (both object keys and values) with its
|
|
10
|
+
* `.toWellFormed()` form, which turns lone surrogates into U+FFFD without touching valid
|
|
11
|
+
* surrogate pairs (real emoji, etc.).
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
/** Recursively sanitize every string value and object key in `value` for well-formed UTF-16.
|
|
15
|
+
* Cycle-safe via a `WeakMap` of already-sanitized objects/arrays. */
|
|
16
|
+
export function sanitizeJsonStrings<T>(value: T): T {
|
|
17
|
+
return sanitizeJsonStringsInner(value, new WeakMap<object, unknown>()) as T;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function sanitizeJsonStringsInner(value: unknown, seen: WeakMap<object, unknown>): unknown {
|
|
21
|
+
if (typeof value === "string") return value.toWellFormed();
|
|
22
|
+
if (!value || typeof value !== "object") return value;
|
|
23
|
+
|
|
24
|
+
const cached = seen.get(value);
|
|
25
|
+
if (cached !== undefined) return cached;
|
|
26
|
+
|
|
27
|
+
if (Array.isArray(value)) {
|
|
28
|
+
const sanitized: unknown[] = [];
|
|
29
|
+
seen.set(value, sanitized);
|
|
30
|
+
for (const item of value) {
|
|
31
|
+
sanitized.push(sanitizeJsonStringsInner(item, seen));
|
|
32
|
+
}
|
|
33
|
+
return sanitized;
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const sanitized: Record<string, unknown> = {};
|
|
37
|
+
seen.set(value, sanitized);
|
|
38
|
+
for (const [key, nestedValue] of Object.entries(value as Record<string, unknown>)) {
|
|
39
|
+
sanitized[key.toWellFormed()] = sanitizeJsonStringsInner(nestedValue, seen);
|
|
40
|
+
}
|
|
41
|
+
return sanitized;
|
|
42
|
+
}
|