Skip to content

Commit 92b38e0

Browse files
committed
Refine async agent and translation regressions
1 parent a3f34f9 commit 92b38e0

12 files changed

Lines changed: 241 additions & 29 deletions

File tree

packages/opencode/src/agent/agent.ts

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -82,13 +82,17 @@ function withBugReportPrompt(agent: Info) {
8282
}
8383
}
8484

85-
const legacyAgentTargets = {
85+
export const legacyAgentTargets = {
8686
build: "ayaz",
8787
general: "quick-high",
8888
plan: "niggli",
8989
explore: "explorer",
9090
} as const satisfies Record<string, string>
9191

92+
export function resolveLegacyAgentTarget(name: string) {
93+
return legacyAgentTargets[name as keyof typeof legacyAgentTargets] ?? name
94+
}
95+
9296
function applyAgentOverride(item: Info, value: AgentOverride) {
9397
const next = {
9498
...item,
@@ -294,9 +298,7 @@ export const layer = Layer.effect(
294298
}),
295299
) satisfies Record<string, Info>
296300

297-
const get = Effect.fnUntraced(function* (agent: string) {
298-
return agents[agent] ?? legacy[agent]
299-
})
301+
const get = (agent: string) => Effect.succeed(agents[agent] ?? legacy[agent])
300302

301303
const list = Effect.fnUntraced(function* () {
302304
const cfg = yield* config.get()

packages/opencode/src/agent/primitive/ayaz.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -55,8 +55,10 @@ Execution contract:
5555
5656
Task async delegation contract:
5757
- \`task_async\` is Ayaz's delegation surface for helper lanes; use it decisively when the work crosses a real lane boundary or when bounded helper work materially improves correctness, coverage, or throughput
58+
- Use the modern async names literally: the tool is \`task_async\` and the repository-discovery subagent is \`explorer\`; do not fall back to legacy \`task\` or \`explore\` names when you mean async helper delegation
5859
- Do not use \`task_async\` for vague, overlapping, or avoidable delegation; every started task must have a concrete question, bounded ownership, and clear evidence expectations
59-
- Use \`explorer\` when local discovery is deep enough that Ayaz would otherwise bloat context, or when location, wiring, ownership, or target narrowing is still unclear after the first narrow local pass
60+
- Use \`explorer\` when local discovery is deep enough that Ayaz would otherwise bloat context, when location, wiring, ownership, or target narrowing is still unclear after the first narrow local pass, or when the user explicitly asks for exhaustive investigation or multi-thread evidence gathering
61+
- If you are about to do three or more non-overlapping discovery passes yourself, stop and launch focused \`task_async\` \`explorer\` work instead of hoarding all discovery inside Ayaz
6062
- Use \`librarian\` for web research, official docs, release behavior, package semantics, framework details, or upstream implementation questions that depend on external sources
6163
- Do not continue broad external research inside Ayaz when \`librarian\` is the correct lane; hand the research off, then reconcile the returned evidence with repository reality and the current implementation target
6264
- Use \`architect\` when a meaningful design, boundary, ownership, contract, storage, migration, or rollout decision remains open after normal repository and external evidence gathering

packages/opencode/src/team/memory.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -793,7 +793,9 @@ export namespace TeamMemory {
793793
throw new Error("atlas_private must be curated by atlas")
794794
}
795795
if (featureArea(area)) {
796-
if (input.actor !== "atlas") throw new Error("feature_memory must be curated by atlas")
796+
if (!["atlas", "ayaz"].includes(input.actor)) {
797+
throw new Error("feature_memory must be curated by atlas or ayaz")
798+
}
797799
if (!scope) throw new Error("scope is required for feature_memory")
798800
if ((input.class ?? Class.enum.knowledge) !== Class.enum.knowledge) {
799801
throw new Error("feature_memory must use class=knowledge")

packages/opencode/src/tool/shared/truncate.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -109,7 +109,7 @@ export namespace Truncate {
109109
yield* fs.writeFileString(file, text).pipe(Effect.orDie)
110110

111111
const hint = hasTaskTool(agent)
112-
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse task_async to have the explore agent process this file with inspect/search. Do NOT read the full file yourself - delegate to save context.`
112+
? `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse task_async to have the explorer subagent process this file with inspect/search. Do NOT read the full file yourself - delegate to save context.`
113113
: `The tool call succeeded but the output was truncated. Full output saved to: ${file}\nUse Search to scan the full content or Inspect with offset/limit-style reads to view specific sections.`
114114

115115
return {

packages/opencode/src/tool/task/task_async.ts

Lines changed: 10 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@ import z from "zod"
33
import { Session } from "../../session"
44
import { SessionID, MessageID } from "../../session/schema"
55
import { MessageV2 } from "../../session/message-v2"
6-
import { Agent } from "../../agent/agent"
6+
import { Agent, resolveLegacyAgentTarget } from "../../agent/agent"
77
import { SessionPrompt } from "../../session/prompt"
88
import { SessionStatus } from "../../session/status"
99
import { Config } from "../../config/config"
@@ -865,10 +865,14 @@ export const TaskAsyncTool = Tool.defineEffect(
865865
) {
866866
const cfg = yield* config.get()
867867
const action = params.action
868+
const requestedSubagent = action === "start" ? params.subagent_type! : undefined
869+
const aliasTarget = requestedSubagent ? resolveLegacyAgentTarget(requestedSubagent) : undefined
870+
const aliasAgent = aliasTarget && aliasTarget !== requestedSubagent ? yield* agent.get(aliasTarget) : undefined
871+
const startSubagent = aliasAgent && aliasAgent.mode === "subagent" && !aliasAgent.hidden ? aliasTarget : requestedSubagent
868872

869873
if (!ctx.extra?.bypassAgentCheck) {
870-
const patterns = action === "start" ? [params.subagent_type!, action] : [action]
871-
const always = action === "start" ? [params.subagent_type!, action] : [action]
874+
const patterns = action === "start" ? [startSubagent!, action] : [action]
875+
const always = action === "start" ? [startSubagent!, action] : [action]
872876
yield* Effect.promise(() =>
873877
ctx.ask({
874878
permission: id,
@@ -877,7 +881,7 @@ export const TaskAsyncTool = Tool.defineEffect(
877881
metadata: {
878882
action,
879883
description: action === "start" ? params.description : undefined,
880-
subagent_type: action === "start" ? params.subagent_type : undefined,
884+
subagent_type: action === "start" ? startSubagent : undefined,
881885
task_id: action === "start" ? undefined : params.task_id,
882886
},
883887
}),
@@ -1219,10 +1223,10 @@ export const TaskAsyncTool = Tool.defineEffect(
12191223
}
12201224
}
12211225

1222-
const txt = block.get(params.subagent_type!)
1226+
const txt = block.get(params.subagent_type!) ?? (startSubagent ? block.get(startSubagent) : undefined)
12231227
if (txt) return yield* Effect.fail(new Error(txt))
12241228

1225-
const next = yield* agent.get(params.subagent_type!)
1229+
const next = yield* agent.get(startSubagent!)
12261230
if (!next) {
12271231
return yield* Effect.fail(new Error(`Unknown agent type: ${params.subagent_type} is not a valid agent type`))
12281232
}

packages/opencode/src/tool/team-tools/memory.ts

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -15,7 +15,7 @@ Memory is not a scratchpad. Do not use it for live coordination, ephemeral execu
1515
1616
Areas:
1717
- \`project_rules\`: prompt-safe reusable project rules
18-
- \`feature_memory\`: Atlas-curated validated feature-purpose and behavior notes keyed by stable \`scope\`
18+
- \`feature_memory\`: validated feature-purpose and behavior notes keyed by stable \`scope\`
1919
- \`atlas_private\`: ATLAS-only private memory that must not leak to subagents
2020
- \`lessons\`: shared durable knowledge, evidence, measurements, and artifacts
2121
@@ -43,7 +43,7 @@ Strict rules:
4343
4. \`promote\` only promotes an active \`lessons\` entry into \`project_rules\`. It is not a general cross-area copy tool.
4444
5. \`remove\` and \`bulk_remove\` require \`reason\`. Unless \`sensitive=true\`, archive first.
4545
6. \`bulk_remove\` is all-or-nothing at the tool layer: if any requested \`id\` is missing or not permitted, nothing is removed.
46-
7. \`feature_memory\` writes are Atlas-only and require \`scope\`, \`class=knowledge\`, and \`kind=package_behavior\` or \`kind=runtime_behavior\`.
46+
7. \`feature_memory\` writes require \`scope\`, \`class=knowledge\`, and \`kind=package_behavior\` or \`runtime_behavior\`.
4747
8. Security \`finding\`/\`remediation\`/\`verification\` entries and performance \`baseline\`/\`measurement\`/\`optimization\` entries require structured \`payload\` evidence.
4848
9. Archive superseded entries; remove only junk, duplicates, or sensitive cleanup.`
4949

@@ -581,7 +581,7 @@ function out(title: string, output: string, metadata: Meta) {
581581

582582
function note(area: TeamMemory.Area) {
583583
if (area === "project_rules") return "prompt-safe reusable project rules"
584-
if (area === "feature_memory") return "Atlas-curated validated feature-purpose and behavior notes"
584+
if (area === "feature_memory") return "validated feature-purpose and behavior notes"
585585
if (area === "atlas_private") return "ATLAS-only private memory"
586586
return "shared durable knowledge, evidence, measurements, and artifacts"
587587
}

packages/opencode/test/team/memory-write.test.ts

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -45,4 +45,44 @@ describe("team memory write", () => {
4545
},
4646
})
4747
})
48+
49+
test("ayaz can rewrite feature memory by stable scope", async () => {
50+
await using dir = await tmpdir({ git: true })
51+
52+
await Instance.provide({
53+
directory: dir.path,
54+
fn: async () => {
55+
const first = await TeamMemory.write({
56+
area: TeamMemory.TeamMemory.Area.enum.feature_memory,
57+
kind: TeamMemory.TeamMemory.Kind.enum.package_behavior,
58+
domain: TeamMemory.TeamMemory.Domain.enum.general,
59+
title: "Memory tool behavior",
60+
content: "first feature note",
61+
scope: "tool.memory",
62+
tags: ["feature", "memory"],
63+
sessionID: SessionID.make("ses_test_memory"),
64+
actor: "ayaz",
65+
})
66+
67+
const second = await TeamMemory.write({
68+
area: TeamMemory.TeamMemory.Area.enum.feature_memory,
69+
kind: TeamMemory.TeamMemory.Kind.enum.package_behavior,
70+
domain: TeamMemory.TeamMemory.Domain.enum.general,
71+
title: "Memory tool behavior",
72+
content: "second feature note",
73+
scope: "tool.memory",
74+
tags: ["feature", "memory"],
75+
sessionID: SessionID.make("ses_test_memory"),
76+
actor: "ayaz",
77+
})
78+
79+
expect(second.id).toBe(first.id)
80+
expect(second.content).toBe("second feature note")
81+
expect(second.scope).toBe("tool.memory")
82+
expect(
83+
(await TeamMemory.list({ area: TeamMemory.TeamMemory.Area.enum.feature_memory, scope: "tool.memory" })).length,
84+
).toBe(1)
85+
},
86+
})
87+
})
4888
})

packages/opencode/test/tool/ayaz-regressions.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,4 +11,11 @@ describe("ayaz source regressions", () => {
1111
expect(text).toContain('git_read: "allow"')
1212
})
1313

14+
test("keeps explicit async explorer delegation guidance in the prompt", async () => {
15+
const text = await Bun.file(new URL("../../src/agent/primitive/ayaz.ts", import.meta.url)).text()
16+
expect(text).toContain("the tool is \\\`task_async\\\` and the repository-discovery subagent is \\\`explorer\\\`")
17+
expect(text).toContain("user explicitly asks for exhaustive investigation or multi-thread evidence gathering")
18+
expect(text).toContain("launch focused \\\`task_async\\\` \\\`explorer\\\` work")
19+
})
20+
1421
})
Lines changed: 112 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,112 @@
1+
import { afterEach, describe, expect } from "bun:test"
2+
import { Effect, Exit, Layer } from "effect"
3+
import { Agent } from "../../src/agent/agent"
4+
import { Config } from "../../src/config"
5+
import * as CrossSpawnSpawner from "../../src/effect/cross-spawn-spawner"
6+
import { Instance } from "../../src/project/instance"
7+
import { Permission } from "../../src/permission"
8+
import { ModelID, ProviderID } from "../../src/provider/schema"
9+
import { Session } from "../../src/session"
10+
import { MessageV2 } from "../../src/session/message-v2"
11+
import { MessageID } from "../../src/session/schema"
12+
import { ToolRegistry, Truncate } from "../../src/tool"
13+
import { provideTmpdirInstance } from "../fixture/fixture"
14+
import { testEffect } from "../lib/effect"
15+
16+
afterEach(async () => {
17+
await Instance.disposeAll()
18+
})
19+
20+
const ref = {
21+
providerID: ProviderID.make("test"),
22+
modelID: ModelID.make("test-model"),
23+
}
24+
25+
const it = testEffect(
26+
Layer.mergeAll(
27+
Agent.defaultLayer,
28+
Config.defaultLayer,
29+
CrossSpawnSpawner.defaultLayer,
30+
Session.defaultLayer,
31+
Truncate.defaultLayer,
32+
ToolRegistry.defaultLayer,
33+
),
34+
)
35+
36+
const seed = Effect.fn("TaskAsyncToolTest.seed")(function* (title = "Pinned") {
37+
const session = yield* Session.Service
38+
const chat = yield* session.create({ title })
39+
const user = yield* session.updateMessage({
40+
id: MessageID.ascending(),
41+
role: "user",
42+
sessionID: chat.id,
43+
agent: "ayaz",
44+
model: ref,
45+
time: { created: Date.now() },
46+
})
47+
const assistant: MessageV2.Assistant = {
48+
id: MessageID.ascending(),
49+
role: "assistant",
50+
parentID: user.id,
51+
sessionID: chat.id,
52+
mode: "ayaz",
53+
agent: "ayaz",
54+
cost: 0,
55+
path: { cwd: "/tmp", root: "/tmp" },
56+
tokens: { input: 0, output: 0, reasoning: 0, cache: { read: 0, write: 0 } },
57+
modelID: ref.modelID,
58+
providerID: ref.providerID,
59+
time: { created: Date.now() },
60+
}
61+
yield* session.updateMessage(assistant)
62+
return { chat, assistant }
63+
})
64+
65+
describe("tool.task_async", () => {
66+
it.live("canonicalizes the legacy explore alias before permission checks", () =>
67+
provideTmpdirInstance(() =>
68+
Effect.gen(function* () {
69+
const registry = yield* ToolRegistry.Service
70+
const tool = (yield* registry.all()).find((item) => item.id === "task_async")
71+
expect(tool).toBeDefined()
72+
if (!tool) throw new Error("task_async tool not found")
73+
74+
const { chat, assistant } = yield* seed()
75+
const asks: Array<Omit<Permission.Request, "id" | "sessionID" | "tool">> = []
76+
const exit = yield* Effect.exit(
77+
tool.execute(
78+
{
79+
action: "start",
80+
description: "inspect alias",
81+
prompt: "look into the target",
82+
subagent_type: "explore",
83+
},
84+
{
85+
sessionID: chat.id,
86+
messageID: assistant.id,
87+
agent: "ayaz",
88+
abort: new AbortController().signal,
89+
messages: [],
90+
metadata: () => Effect.void,
91+
ask: (input) =>
92+
Effect.sync(() => {
93+
asks.push(input)
94+
throw new Error("stop after ask")
95+
}),
96+
},
97+
),
98+
)
99+
100+
expect(Exit.isFailure(exit)).toBe(true)
101+
expect(asks).toHaveLength(1)
102+
expect(asks[0]?.patterns).toEqual(["explorer", "start"])
103+
expect(asks[0]?.always).toEqual(["explorer", "start"])
104+
expect(asks[0]?.metadata).toMatchObject({
105+
action: "start",
106+
description: "inspect alias",
107+
subagent_type: "explorer",
108+
})
109+
}),
110+
),
111+
)
112+
})

packages/opencode/test/tool/truncation.test.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -181,6 +181,9 @@ describe("Truncate", () => {
181181

182182
expect(result.truncated).toBe(true)
183183
expect(result.content).toContain("The tool call succeeded but the output was truncated")
184+
expect(result.content).toContain("task_async")
185+
expect(result.content).toContain("explorer subagent")
186+
expect(result.content).not.toContain("explore agent")
184187
if (!result.truncated) throw new Error("expected truncated")
185188
expect(result.outputPath).toContain("tool_")
186189
})

0 commit comments

Comments
 (0)