@hueyexe/opencode-ensemble 0.18.0 → 0.20.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +19 -11
- package/dist/index.js +7987 -22031
- package/package.json +2 -2
- package/src/tool-arg-hint.ts +71 -0
- package/src/tools/team-spawn.ts +27 -3
- package/src/tools/team-tasks-add.ts +17 -0
- package/src/types.ts +4 -0
- package/src/v2-client.ts +98 -13
- package/src/v2-setup.ts +14 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@hueyexe/opencode-ensemble",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.20.0",
|
|
4
4
|
"description": "Agent teams for OpenCode — parallel agents with peer-to-peer communication, shared tasks, and coordinated execution",
|
|
5
5
|
"module": "src/index.ts",
|
|
6
6
|
"main": "dist/index.js",
|
|
@@ -48,7 +48,7 @@
|
|
|
48
48
|
"@opentui/core": "0.5.10",
|
|
49
49
|
"@opentui/solid": "0.5.10",
|
|
50
50
|
"@types/bun": "latest",
|
|
51
|
-
"solid-js": "1.9.
|
|
51
|
+
"solid-js": "1.9.4"
|
|
52
52
|
},
|
|
53
53
|
"peerDependencies": {
|
|
54
54
|
"typescript": "^5",
|
|
@@ -0,0 +1,71 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Weak-model tool-argument fumble detection and error hints (issue #39).
|
|
3
|
+
*
|
|
4
|
+
* Some models trained on other harness conventions call OpenCode's built-in
|
|
5
|
+
* tools with aliased parameter names (e.g. `cmd` instead of the `shell`
|
|
6
|
+
* tool's `command`). OpenCode V2 rejects the call with a generic validation
|
|
7
|
+
* error that the model may fail to act on. This module detects that exact
|
|
8
|
+
* dead-call shape and produces an explicit hint appended to the error so
|
|
9
|
+
* the model can self-correct. Arguments are never rewritten — the model
|
|
10
|
+
* stays in the correction loop.
|
|
11
|
+
*
|
|
12
|
+
* V2 only: on V1, argument validation happens at the model-stream parse
|
|
13
|
+
* level, so tool hooks never see the failed call (see issue #39 triage).
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* Canonical parameter plus the aliases weak models emit for it. Only the
|
|
18
|
+
* confirmed failure mode is listed; extend per-tool as reports come in.
|
|
19
|
+
*/
|
|
20
|
+
const ARG_ALIASES: Readonly<Record<string, { param: string; aliases: readonly string[] }>> = {
|
|
21
|
+
shell: { param: "command", aliases: ["cmd", "bash", "script"] },
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
/**
|
|
25
|
+
* Return the alias a weak model used instead of the tool's canonical
|
|
26
|
+
* parameter, or null when the input is not a recognized fumble. A fumble is
|
|
27
|
+
* only recognized when the canonical parameter is absent and a known alias
|
|
28
|
+
* is present as a non-empty string — i.e. only on calls that are already
|
|
29
|
+
* guaranteed to fail validation.
|
|
30
|
+
*/
|
|
31
|
+
export function detectArgFumble(tool: string, input: unknown): string | null {
|
|
32
|
+
const entry = ARG_ALIASES[tool]
|
|
33
|
+
if (!entry) return null
|
|
34
|
+
if (input === null || typeof input !== "object" || Array.isArray(input)) return null
|
|
35
|
+
const args = input as Record<string, unknown>
|
|
36
|
+
if (entry.param in args) return null
|
|
37
|
+
for (const alias of entry.aliases) {
|
|
38
|
+
const value = args[alias]
|
|
39
|
+
if (typeof value === "string" && value !== "") return alias
|
|
40
|
+
}
|
|
41
|
+
return null
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Build the hint text for a fumbled tool call, or null when the input is
|
|
46
|
+
* not a recognized fumble.
|
|
47
|
+
*/
|
|
48
|
+
export function buildArgHint(tool: string, input: unknown): string | null {
|
|
49
|
+
const entry = ARG_ALIASES[tool]
|
|
50
|
+
if (!entry) return null
|
|
51
|
+
const alias = detectArgFumble(tool, input)
|
|
52
|
+
if (!alias) return null
|
|
53
|
+
return (
|
|
54
|
+
`Hint: the "${tool}" tool has no "${alias}" parameter. The correct parameter ` +
|
|
55
|
+
`name is "${entry.param}" — call the tool again with the same arguments, ` +
|
|
56
|
+
`moving the "${alias}" value into "${entry.param}".`
|
|
57
|
+
)
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Append the fumble hint to a failed tool call's error message in place.
|
|
62
|
+
* Returns true when the message was modified. Never throws — a malformed
|
|
63
|
+
* error or input leaves the original message untouched.
|
|
64
|
+
*/
|
|
65
|
+
export function enrichToolError(tool: string, error: { message?: unknown }, input: unknown): boolean {
|
|
66
|
+
if (typeof error.message !== "string") return false
|
|
67
|
+
const hint = buildArgHint(tool, input)
|
|
68
|
+
if (!hint) return false
|
|
69
|
+
error.message = `${error.message}\n\n${hint}`
|
|
70
|
+
return true
|
|
71
|
+
}
|
package/src/tools/team-spawn.ts
CHANGED
|
@@ -87,7 +87,11 @@ export async function executeTeamSpawn(
|
|
|
87
87
|
.get(teamInfo.teamId, args.name)
|
|
88
88
|
if (existing) throw new Error(`Teammate "${args.name}" already exists in team "${teamInfo.teamName}"`)
|
|
89
89
|
|
|
90
|
-
|
|
90
|
+
// Prefer the agent's own permission rules (V2) so any agent that denies
|
|
91
|
+
// edits — not only the built-in plan/explore — is treated as read-only and
|
|
92
|
+
// never gets a worktree or a shell allow. Fall back to the built-in names.
|
|
93
|
+
const isReadOnly = (await deps.client.agent?.readOnly(agent).catch(() => undefined))
|
|
94
|
+
?? (agent === "plan" || agent === "explore")
|
|
91
95
|
const useWorktree = args.worktree !== false && !isReadOnly && !isWorktreeDirectory(deps.directory)
|
|
92
96
|
const usePlanApproval = args.plan_approval === true
|
|
93
97
|
|
|
@@ -167,6 +171,12 @@ export async function executeTeamSpawn(
|
|
|
167
171
|
if (worktreeDir) {
|
|
168
172
|
permission.push(
|
|
169
173
|
{ permission: "edit", pattern: `${worktreeDir}/**`, action: "allow" },
|
|
174
|
+
// V2 checks files inside the session's location as paths relative to
|
|
175
|
+
// it. session.create above binds the location to the worktree (via
|
|
176
|
+
// workspaceID, or directory as fallback), so this rule scopes edits
|
|
177
|
+
// to the worktree. Files outside the worktree remain gated by
|
|
178
|
+
// external_directory.
|
|
179
|
+
{ permission: "edit", pattern: "*", action: "allow" },
|
|
170
180
|
)
|
|
171
181
|
if (!isReadOnly) {
|
|
172
182
|
permission.push({ permission: "bash", pattern: "*", action: "allow" })
|
|
@@ -200,7 +210,14 @@ export async function executeTeamSpawn(
|
|
|
200
210
|
parentID: sessionId,
|
|
201
211
|
title: `${args.name} (@${agent} teammate)`,
|
|
202
212
|
permission,
|
|
203
|
-
...(workspaceId
|
|
213
|
+
...(workspaceId
|
|
214
|
+
? { workspaceID: workspaceId }
|
|
215
|
+
// Workspace binding is preferred; when it is unavailable, bind the
|
|
216
|
+
// session to the worktree directory directly. Without a location,
|
|
217
|
+
// the teammate's location is the project root, edit paths are
|
|
218
|
+
// evaluated relative to it, and the `edit * allow` rule below
|
|
219
|
+
// would grant edits to the whole shared tree (PR #46 review).
|
|
220
|
+
: worktreeDir ? { directory: worktreeDir } : {}),
|
|
204
221
|
}),
|
|
205
222
|
getSpawnTimeout(), `session.create for "${args.name}"`
|
|
206
223
|
)
|
|
@@ -445,6 +462,13 @@ export async function executeTeamSpawn(
|
|
|
445
462
|
})
|
|
446
463
|
|
|
447
464
|
const branchInfo = worktreeBranch ? ` (branch: ${worktreeBranch})` : ""
|
|
465
|
+
// Isolation is a claim the lead relies on, so say plainly when it did not
|
|
466
|
+
// complete. The toast alone left "spawned" as the only signal (issue #40).
|
|
467
|
+
// Covers both failure modes: no worktree at all (shared directory) or a
|
|
468
|
+
// worktree whose branch could not be created (unmergeable detached HEAD).
|
|
469
|
+
const isolationInfo = useWorktree && !worktreeBranch
|
|
470
|
+
? ` (WARNING: worktree isolation did not complete — ${args.name} may share your working directory or lack a mergeable branch; edits can collide and team_merge may fail)`
|
|
471
|
+
: ""
|
|
448
472
|
const planInfo = usePlanApproval ? " [plan mode — will send plan for approval]" : ""
|
|
449
473
|
const claimInfo = claimedTaskContent
|
|
450
474
|
? ` (claimed task: ${args.claim_task})`
|
|
@@ -452,5 +476,5 @@ export async function executeTeamSpawn(
|
|
|
452
476
|
// Reset circuit breaker on success
|
|
453
477
|
spawnFailures.delete(teamInfo.teamId)
|
|
454
478
|
log(`spawn:done name=${args.name} sessionId=${childSessionId}`)
|
|
455
|
-
return `Teammate "${args.name}" spawned (agent: ${agent})${branchInfo}${planInfo}${claimInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
|
|
479
|
+
return `Teammate "${args.name}" spawned (agent: ${agent})${branchInfo}${isolationInfo}${planInfo}${claimInfo}. They are working on: ${args.prompt.slice(0, 120)}${args.prompt.length > 120 ? "..." : ""}`
|
|
456
480
|
}
|
|
@@ -19,6 +19,23 @@ export async function executeTeamTasksAdd(
|
|
|
19
19
|
): Promise<string> {
|
|
20
20
|
const teamInfo = requireTeamMember(deps, sessionId)
|
|
21
21
|
|
|
22
|
+
// Reject unknown dependency IDs before inserting anything: a task blocked on
|
|
23
|
+
// an ID that never existed can never unblock (issue #40). IDs are generated
|
|
24
|
+
// here, so new tasks can only depend on existing ones and no cycle can form.
|
|
25
|
+
const unknown = new Set<string>()
|
|
26
|
+
for (const task of args.tasks) {
|
|
27
|
+
for (const depId of task.depends_on ?? []) {
|
|
28
|
+
const exists = deps.db.query("SELECT 1 FROM team_task WHERE id = ? AND team_id = ?").get(depId, teamInfo.teamId)
|
|
29
|
+
if (!exists) unknown.add(depId)
|
|
30
|
+
}
|
|
31
|
+
}
|
|
32
|
+
if (unknown.size > 0) {
|
|
33
|
+
throw new Error(
|
|
34
|
+
`Unknown task ID${unknown.size > 1 ? "s" : ""} in depends_on: ${[...unknown].join(", ")}. ` +
|
|
35
|
+
"Use IDs from team_tasks_list, or add the prerequisite tasks first and reference the IDs they return.",
|
|
36
|
+
)
|
|
37
|
+
}
|
|
38
|
+
|
|
22
39
|
const ids: string[] = []
|
|
23
40
|
const now = Date.now()
|
|
24
41
|
|
package/src/types.ts
CHANGED
|
@@ -35,6 +35,10 @@ export interface PermissionRule {
|
|
|
35
35
|
* Makes mocking trivial in tests.
|
|
36
36
|
*/
|
|
37
37
|
export interface PluginClient {
|
|
38
|
+
/** Optional agent introspection (V2). Undefined answers fall back to name checks. */
|
|
39
|
+
agent?: {
|
|
40
|
+
readOnly(agent: string): Promise<boolean | undefined>
|
|
41
|
+
}
|
|
38
42
|
session: {
|
|
39
43
|
create(options: {
|
|
40
44
|
parentID?: string
|
package/src/v2-client.ts
CHANGED
|
@@ -1,8 +1,11 @@
|
|
|
1
1
|
import type { PluginClient } from "./types"
|
|
2
|
+
import { log } from "./log"
|
|
2
3
|
import { runCommand } from "./process"
|
|
3
4
|
|
|
4
5
|
/** V2 plugin context subset Ensemble needs (structural, mock-friendly). */
|
|
5
6
|
export interface V2Context {
|
|
7
|
+
/** Plugin location; `project.id` is required by the 2.0.19+ worktree API. */
|
|
8
|
+
location?: { directory?: string; project?: { id?: string } }
|
|
6
9
|
session: {
|
|
7
10
|
create(input: Record<string, unknown>): Promise<{ id: string }>
|
|
8
11
|
prompt(input: Record<string, unknown>): Promise<unknown>
|
|
@@ -17,12 +20,39 @@ export interface V2Context {
|
|
|
17
20
|
worktree: {
|
|
18
21
|
create(input: Record<string, unknown>): Promise<{ directory: string }>
|
|
19
22
|
remove(input: Record<string, unknown>): Promise<unknown>
|
|
20
|
-
list(): Promise<Array<{ directory: string }>>
|
|
21
|
-
refresh(): Promise<unknown>
|
|
23
|
+
list(input?: Record<string, unknown>): Promise<Array<{ directory: string }>>
|
|
24
|
+
refresh(input?: Record<string, unknown>): Promise<unknown>
|
|
22
25
|
}
|
|
23
26
|
permission: {
|
|
24
|
-
|
|
27
|
+
/** Present on 2.0.3–2.0.18 only; removed in 2.0.19 (see issue #40). */
|
|
28
|
+
rules?(input: Record<string, unknown>): Promise<unknown>
|
|
25
29
|
}
|
|
30
|
+
/** Agent lookup (2.0.19+): exposes each agent's resolved permission rules. */
|
|
31
|
+
agent?: {
|
|
32
|
+
get(input: Record<string, unknown>): Promise<unknown>
|
|
33
|
+
}
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type PermissionRuleV2 = { action?: unknown; resource?: unknown; effect?: unknown }
|
|
37
|
+
|
|
38
|
+
/** OpenCode wildcard match: `*` matches any run of characters. */
|
|
39
|
+
function wildcardMatch(value: string, pattern: string): boolean {
|
|
40
|
+
const escaped = pattern.replace(/[.+?^${}()|[\]\\]/g, "\\$&").replace(/\*/g, ".*")
|
|
41
|
+
return new RegExp(`^${escaped}$`).test(value)
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* True when an agent's own rules deny editing everywhere. OpenCode evaluates
|
|
46
|
+
* the last matching rule, so this mirrors `evaluate("edit", "*")` over the
|
|
47
|
+
* agent ruleset. Undefined when the rules carry no edit decision.
|
|
48
|
+
*/
|
|
49
|
+
export function agentRulesAreReadOnly(rules: unknown): boolean | undefined {
|
|
50
|
+
if (!Array.isArray(rules)) return undefined
|
|
51
|
+
const match = [...(rules as PermissionRuleV2[])].reverse().find((rule) =>
|
|
52
|
+
typeof rule?.action === "string" && typeof rule?.resource === "string"
|
|
53
|
+
&& wildcardMatch("edit", rule.action) && (rule.resource === "*" || rule.resource === "**"))
|
|
54
|
+
if (!match) return undefined
|
|
55
|
+
return match.effect === "deny"
|
|
26
56
|
}
|
|
27
57
|
|
|
28
58
|
/** Prefix marking a workspace id that is really a local directory (see below). */
|
|
@@ -32,6 +62,10 @@ const DIR_WORKSPACE_PREFIX = "v2dir:"
|
|
|
32
62
|
export interface V2ClientDeps {
|
|
33
63
|
/** Current branch checked out in a directory, or null when unknown. */
|
|
34
64
|
gitBranch?: (directory: string) => Promise<string | null>
|
|
65
|
+
/** Create and check out a branch in a directory. Returns the effective
|
|
66
|
+
* branch name, or null when the branch could not be created or checked
|
|
67
|
+
* out — never record the requested name without this confirmation. */
|
|
68
|
+
gitCreateBranch?: (directory: string, branch: string) => Promise<string | null>
|
|
35
69
|
/** Worktree directory currently on a branch, or null when none. */
|
|
36
70
|
gitDir?: (branch: string) => Promise<string | null>
|
|
37
71
|
/** RPC emitter for companion events (team_view navigation). */
|
|
@@ -49,6 +83,31 @@ async function gitBranchDefault(directory: string): Promise<string | null> {
|
|
|
49
83
|
}
|
|
50
84
|
}
|
|
51
85
|
|
|
86
|
+
/** Create and check out a branch in a directory via git. Returns the
|
|
87
|
+
* effective branch name, or null when neither create nor switch worked —
|
|
88
|
+
* callers must record the return value, not the requested name, so a
|
|
89
|
+
* failed create can never be recorded as a live branch. */
|
|
90
|
+
async function gitCreateBranchDefault(directory: string, branch: string): Promise<string | null> {
|
|
91
|
+
try {
|
|
92
|
+
await runCommand(["git", "switch", "-c", branch], { cwd: directory })
|
|
93
|
+
return branch
|
|
94
|
+
} catch (err) {
|
|
95
|
+
// The branch may already exist (re-spawn after the abort-cleanup race
|
|
96
|
+
// documented in AGENTS.md) — switch to it instead of recording a name
|
|
97
|
+
// with no ref behind it. Log both attempts; swallowing failures silently
|
|
98
|
+
// made team_merge squash a stale branch and silently lose new work
|
|
99
|
+
// (PR #46 review).
|
|
100
|
+
log(`v2-client:gitCreateBranch:create-failed directory=${directory} branch=${branch} err=${err instanceof Error ? err.message : String(err)} — trying existing branch`)
|
|
101
|
+
try {
|
|
102
|
+
await runCommand(["git", "switch", branch], { cwd: directory })
|
|
103
|
+
return branch
|
|
104
|
+
} catch (err2) {
|
|
105
|
+
log(`v2-client:gitCreateBranch:switch-failed directory=${directory} branch=${branch} err=${err2 instanceof Error ? err2.message : String(err2)}`)
|
|
106
|
+
return null
|
|
107
|
+
}
|
|
108
|
+
}
|
|
109
|
+
}
|
|
110
|
+
|
|
52
111
|
/** Concatenate the text parts of a promptAsync payload into one string. */
|
|
53
112
|
function joinParts(parts: Array<{ type: "text"; text: string }>): string {
|
|
54
113
|
return parts
|
|
@@ -88,7 +147,24 @@ function joinParts(parts: Array<{ type: "text"; text: string }>): string {
|
|
|
88
147
|
*/
|
|
89
148
|
export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginClient {
|
|
90
149
|
const gitBranch = deps.gitBranch ?? gitBranchDefault
|
|
150
|
+
const gitCreateBranch = deps.gitCreateBranch ?? gitCreateBranchDefault
|
|
151
|
+
// 2.0.19+ worktree endpoints require the project; older hosts ignore it.
|
|
152
|
+
const project = (): Record<string, unknown> => {
|
|
153
|
+
const projectID = ctx.location?.project?.id
|
|
154
|
+
return projectID ? { projectID } : {}
|
|
155
|
+
}
|
|
91
156
|
return {
|
|
157
|
+
agent: {
|
|
158
|
+
readOnly: async (agent: string) => {
|
|
159
|
+
if (!ctx.agent) return undefined
|
|
160
|
+
try {
|
|
161
|
+
const info = (await ctx.agent.get({ agentID: agent })) as { permissions?: unknown; data?: { permissions?: unknown } } | undefined
|
|
162
|
+
return agentRulesAreReadOnly(info?.permissions ?? info?.data?.permissions)
|
|
163
|
+
} catch {
|
|
164
|
+
return undefined
|
|
165
|
+
}
|
|
166
|
+
},
|
|
167
|
+
},
|
|
92
168
|
session: {
|
|
93
169
|
create: async (options) => {
|
|
94
170
|
const input: Record<string, unknown> = { title: options.title }
|
|
@@ -114,11 +190,12 @@ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginC
|
|
|
114
190
|
}
|
|
115
191
|
}
|
|
116
192
|
const created = await ctx.session.create(input)
|
|
117
|
-
if (permissions) {
|
|
118
|
-
//
|
|
119
|
-
//
|
|
120
|
-
//
|
|
121
|
-
// agent
|
|
193
|
+
if (permissions && typeof ctx.permission?.rules === "function") {
|
|
194
|
+
// 2.0.3–2.0.18 merged agent rules after session rules, so create()
|
|
195
|
+
// permissions lost to the agent's and had to be re-asserted. From
|
|
196
|
+
// 2.0.19 the session ruleset merges last (core permission.ts
|
|
197
|
+
// `merge(agent.permissions, session.permissions)` with last-match
|
|
198
|
+
// evaluation) and rules() no longer exists, so create() is enough.
|
|
122
199
|
await ctx.permission.rules({ sessionID: created.id, permissions })
|
|
123
200
|
}
|
|
124
201
|
return { data: { id: created.id } }
|
|
@@ -177,16 +254,24 @@ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginC
|
|
|
177
254
|
worktree: {
|
|
178
255
|
create: async (options) => {
|
|
179
256
|
const name = (options.worktreeCreateInput as { name?: string } | undefined)?.name ?? "ensemble-worktree"
|
|
180
|
-
const created = await ctx.worktree.create({ name })
|
|
181
|
-
|
|
257
|
+
const created = await ctx.worktree.create({ ...project(), name })
|
|
258
|
+
let branch = await gitBranch(created.directory)
|
|
259
|
+
if (!branch) {
|
|
260
|
+
// 2.0.32+ creates worktrees detached; team_merge needs a named
|
|
261
|
+
// branch. Record the effective branch only — never the requested
|
|
262
|
+
// name — so a failed create cannot point team_merge at a stale
|
|
263
|
+
// same-named branch whose commits would silently shadow new work
|
|
264
|
+
// (PR #46 review).
|
|
265
|
+
branch = (await gitCreateBranch(created.directory, name)) ?? ""
|
|
266
|
+
}
|
|
182
267
|
return { data: { name, branch, directory: created.directory } }
|
|
183
268
|
},
|
|
184
269
|
remove: async (options) => {
|
|
185
270
|
const directory = (options.worktreeRemoveInput as { directory: string }).directory
|
|
186
|
-
return ctx.worktree.remove({ directory, force: false })
|
|
271
|
+
return ctx.worktree.remove({ ...project(), directory, force: false })
|
|
187
272
|
},
|
|
188
273
|
list: async () => {
|
|
189
|
-
const entries = await ctx.worktree.list()
|
|
274
|
+
const entries = await ctx.worktree.list(project())
|
|
190
275
|
return {
|
|
191
276
|
data: entries.map((entry) => ({
|
|
192
277
|
name: entry.directory.split("/").pop() ?? entry.directory,
|
|
@@ -195,7 +280,7 @@ export function createV2Client(ctx: V2Context, deps: V2ClientDeps = {}): PluginC
|
|
|
195
280
|
})),
|
|
196
281
|
}
|
|
197
282
|
},
|
|
198
|
-
reset: async () => ctx.worktree.refresh(),
|
|
283
|
+
reset: async () => ctx.worktree.refresh(project()),
|
|
199
284
|
},
|
|
200
285
|
workspace: {
|
|
201
286
|
create: async (options) => {
|
package/src/v2-setup.ts
CHANGED
|
@@ -11,6 +11,7 @@ import type { ToolDeps } from "./types"
|
|
|
11
11
|
import { ProgressTracker } from "./progress"
|
|
12
12
|
import { checkToolIsolation, shouldNudgeIdleMember } from "./hooks"
|
|
13
13
|
import { hasReportedCompletion } from "./messaging"
|
|
14
|
+
import { enrichToolError } from "./tool-arg-hint"
|
|
14
15
|
import { rehydrateRegistry } from "./recovery"
|
|
15
16
|
import { findTeamBySession } from "./types"
|
|
16
17
|
import { loadConfig } from "./config"
|
|
@@ -24,7 +25,8 @@ import { vlog } from "./log"
|
|
|
24
25
|
|
|
25
26
|
/** V2 setup context subset (structural, mock-friendly). */
|
|
26
27
|
export interface V2SetupContext {
|
|
27
|
-
location: { directory: string }
|
|
28
|
+
location: { directory: string; project?: { id?: string } }
|
|
29
|
+
agent?: V2Context["agent"]
|
|
28
30
|
options: Record<string, unknown>
|
|
29
31
|
session: V2Context["session"] & {
|
|
30
32
|
hook(name: string, cb: (event: never) => unknown): Promise<{ dispose(): Promise<void> }>
|
|
@@ -251,6 +253,17 @@ export async function setupEnsemble(
|
|
|
251
253
|
input: unknown
|
|
252
254
|
status: string
|
|
253
255
|
result?: { content?: string | Array<{ type?: string; text?: string }> }
|
|
256
|
+
error?: { message?: unknown }
|
|
257
|
+
}
|
|
258
|
+
if (event.status === "error" && event.error) {
|
|
259
|
+
// Weak-model arg fumbles (issue #39): the call is already dead from
|
|
260
|
+
// validation, so append an explicit "the param is X, not Y" hint to
|
|
261
|
+
// the error the model sees. Arguments are never rewritten — the
|
|
262
|
+
// model stays in the correction loop. Fail-open by design: a
|
|
263
|
+
// non-fumble error is returned untouched.
|
|
264
|
+
if (enrichToolError(event.tool, event.error, event.input)) {
|
|
265
|
+
vlog(`tool-arg-hint:fired tool=${event.tool} session=${event.sessionID}`)
|
|
266
|
+
}
|
|
254
267
|
}
|
|
255
268
|
if (event.tool === "question") {
|
|
256
269
|
// Feed the answer text to purge approvals (team_cleanup confirm flow).
|