Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
62 changes: 62 additions & 0 deletions devlog/2026-08-13_v2-tools-dualshape/DESIGN.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,62 @@
# DESIGN — V2 tool-shape adapter (dual-shape)

## Context

`@bili/core`'s tool makers (`makeCompressTool`, etc.) return a V1 `ToolDef`:

```ts
type ToolDef = {
description: string
args: Record<string, z.ZodTypeAny> // zod shape (V1 hosts consume this directly)
execute(args, ctx: ToolContext): Promise<string | { output: string; metadata? }>
}
```

opencode V2's `tools.add()` wants the AI-SDK shape:

```ts
type V2ToolInfo = {
name: string
description: string
input: Record<string, unknown> // a JSON Schema
execute(input, ctx): Promise<{ content: string }>
}
```

## Decision — derive the JSON Schema from zod (do not hand-write)

PR #8 hand-wrote four JSON Schemas mirroring the zod shapes. That works but **drifts**: any future edit to a tool's zod `args` must be mirrored by hand in the V2 schema, with no compiler help.

zod 4.4.3 (already a bundled dependency) ships `z.toJSONSchema(zodObject)`. We derive each tool's `input` from its own `args`:

```ts
input: z.toJSONSchema(z.object(tool.args))
```

The V2 schema is therefore **always a faithful projection of the V1 definition** — single source of truth, zero drift. This is strictly better than #8's hand-written schemas and is the reason this PR does not copy them verbatim.

## Decision — adapter module, not a second package

The bridge lives in a new sibling file `packages/billion-context-opencode/src/v2-tools.ts` (next to `messages-v1.ts` / `messages-v2.ts`), consumed only by `setupV2`. It:

- maps `V2ToolContext` → V1 `ToolContext` (`callID ← ctx.id`, `directory`/`worktree ← ""` since V2 has none and the bili_* tools key persistence off `sessionID` only),
- unwraps the V1 result (`string | { output }`) into V2's `{ content }`.

`@bili/core` already exports `ToolDef` and `ToolContext`, so the adapter imports them — `@bili/core` stays host-agnostic (it knows nothing of opencode V2).

This keeps the single published package and the dual-shape export untouched (AGENTS.md §2.3 / §2.7), unlike #8's `packages/v2/` second package.

## Decision — marker exported for a regression test

`SYSTEM_MARKER` is now `export`ed from `index.ts`. A test asserts `SYSTEM_PROMPT.includes(SYSTEM_MARKER)`, so any future edit to either side that breaks the match fails CI instead of silently reintroducing duplicate-prompt accumulation.

## What is NOT changed

- The dual-shape default export (`Object.assign(biliAcpPluginV1, { id, setup: setupV2 })`).
- The V1 plugin factory and its `tool:{}` map (still uses the raw V1 makers).
- `@bili/core` (V1 `ToolDef` untouched).
- `runPipelineV2` pipeline logic (nudge, reassemble, model-limit resolution) — identical to master; only the `tools.add` block and the marker constant change.

## Risk / limitation

The V2 tool shape is verified by unit test (shape, schema projection, ctx mapping, `{ content }` unwrap) but not against a live opencode2 runtime in this repo's CI (same limitation #8 had). `z.toJSONSchema` emits standard JSON Schema Draft 2020-12; if a future opencode2 `ValueSchema` rejects the `$schema` keyword, a one-line strip resolves it.
29 changes: 29 additions & 0 deletions devlog/2026-08-13_v2-tools-dualshape/REQ.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
# REQ — V2 tool-shape & idempotent system prompt (dual-shape)

## Problem

The dual-shape V2 path in `packages/billion-context-opencode/src/index.ts` (`setupV2`) registered the four `bili_*` tools by spreading `@bili/core`'s V1 `ToolDef` straight into `tools.add()`. The V1 `ToolDef` is `{ description, args(zod), execute(args, ToolContext) }`, but opencode V2's `tools.add()` expects the AI-SDK shape `{ name, description, input(JSON Schema), execute(input, ctx) → { content } }`. Concretely broken:

1. **No `name`** on the registered tool (V1 derives the name from the `tool:{}` map key; V2 needs it on the object).
2. **`args` is a zod object**, not a JSON Schema — V2's `Info.input` is a JSON Schema / ValueSchema, so the model never saw a usable schema.
3. **`execute` ctx unmapped** — V1 `ToolContext` requires `directory`/`worktree`/`messageID`/`agent` as non-optional strings; V2 provides `{ sessionID, agent?, messageID?, id, progress? }` (no `directory`/`worktree`).

Separately, the V2 context hook's system-prompt upsert used `SYSTEM_MARKER = "BILI CONTEXT MANAGEMENT"`, but `@bili/core`'s `SYSTEM_PROMPT` actually begins with `ACP TOOLS (billion-context)`. So `findIndex` always returned `-1` and a **duplicate system prompt was appended on every dispatch**.

Both bugs were reported in PR #8 (branch `fix/v2-tools`, by rorshopping), which fixed them inside a **separate second package** `packages/v2/` (`billion-context-opencode-v2`). That packaging contradicts AGENTS.md §2.3 (dual-shape is load-bearing; master deliberately has a single package). The fixes are correct; the packaging is not.

## Acceptance criteria

1. The four V2 tools registered by `setupV2` carry `{ name, description, input(JSON Schema), execute }`.
2. The V2 `input` schema is derived from each tool's zod `args` so it cannot drift.
3. V2 `execute` maps the V2 ctx onto the V1 `ToolContext` and returns `{ content }`.
4. `SYSTEM_MARKER` matches the real `SYSTEM_PROMPT` header; the upsert is idempotent.
5. A regression test guards both invariants.
6. `npm run typecheck`, `npm test`, `npm run build`, `node smoke.mjs` all pass.
7. No change to the V1 path, the dual-shape export, or `@bili/core`. No new runtime dependency.

## Constraints

- AGENTS.md §2.3: keep ONE package, ONE dual-shape entry. Do NOT add `packages/v2/`.
- AGENTS.md §2.7: published `dist/index.js` stays zero-runtime-dependency.
- Credit the fix logic to rorshopping (PRs #4 / #8); supersede both.
41 changes: 41 additions & 0 deletions devlog/2026-08-13_v2-tools-dualshape/WORKLOG.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,41 @@
# WORKLOG — V2 tool-shape & idempotent system prompt (dual-shape)

## Branch

`2026-08-13_v2-tools-dualshape` (off `origin/master` @ `16463b5`, after #13 merged).

## Origin of the fix

PRs #4 (rorshopping, root `v2/`) and #8 (ranxianglei branch `fix/v2-tools`, `packages/v2/`) both target V2. #8 fixes two real bugs (tool shape, system-prompt marker) but as a **second package**, which contradicts AGENTS.md §2.3. This PR ports #8's fixes into the existing dual-shape entry and supersedes both. Fix logic credited to rorshopping.

## Changes

- **NEW** `packages/billion-context-opencode/src/v2-tools.ts`
- `toV2Tool(name, tool)` wraps a V1 `ToolDef` → `{ name, description, input, execute }`.
- `input = z.toJSONSchema(z.object(tool.args))` (zod 4.4.3 built-in) — drift-free.
- `execute` maps V2 ctx → V1 `ToolContext` (`callID ← ctx.id`, empty `directory`/`worktree`) and unwraps result → `{ content }`.
- `makeV2{Compress,Decompress,Search,Status}Tool(runtime)`.
- `packages/billion-context-opencode/src/index.ts`
- `SYSTEM_MARKER`: `"BILI CONTEXT MANAGEMENT"` → `"ACP TOOLS (billion-context)"` (matches `@bili/core` SYSTEM_PROMPT header at `system-prompt.ts:14`). Now `export`ed.
- `setupV2` `tools.add` block: `makeCompressTool` → `makeV2CompressTool` (×4). V1 path unchanged.
- **NEW** `packages/billion-context-opencode/tests/v2-tools.test.ts` — 4 tests:
1. all four tools expose `{ name, description, input{type:"object",properties}, execute }`;
2. compress input schema projects content[].{startId,endId,summary}, required=["content"];
3. execute maps ctx + returns `{ content: string }` (exercised via `bili_status`);
4. `SYSTEM_PROMPT.includes(SYSTEM_MARKER)` (idempotency invariant).

## Verification

| Check | Result |
|------|--------|
| `npm run typecheck` (both workspaces) | ✅ pass |
| `npm test` | ✅ **54/54** (50 → 54, +4 V2 tests) |
| `npm run build` | ✅ pass (`dist/index.js` 677.51 KB; +1 KB vs 676.5 — z.toJSONSchema is already in bundled zod) |
| `node smoke.mjs` | ✅ ALL SMOKE TESTS PASSED |
| `bash scripts/ci/check-pr.sh 2026-08-13_v2-tools-dualshape origin/master` | (runs in CI) |

## Follow-ups (out of scope)

- Close #4 and #8 once this merges (fixes incorporated into the canonical package).
- #8 notes the kernel hardcodes the generic tool name `'compress'` in pairing rules, leaking consumed `bili_compress` invocations — separate kernel-side fix (rorshopping has a PR-ready branch on `acp-kernel`).
- Live opencode2 runtime verification not in CI (same gap as #8).
20 changes: 15 additions & 5 deletions packages/billion-context-opencode/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,12 @@ import {
makeNudgeMessage as makeNudgeMessageV2,
type V2Message,
} from "./messages-v2.js"
import {
makeV2CompressTool,
makeV2DecompressTool,
makeV2SearchTool,
makeV2StatusTool,
} from "./v2-tools.js"

// ---------------------------------------------------------------------------
// Shared adapter-config builder. Both the V1 entry (input, options) and the V2
Expand Down Expand Up @@ -188,7 +194,11 @@ async function biliAcpPluginV1(
// { id, setup } (V2) satisfies BOTH loaders. See the dual-shape export below.
// ===========================================================================

const SYSTEM_MARKER = "BILI CONTEXT MANAGEMENT"
/** Must match the first line of `@bili/core`'s SYSTEM_PROMPT so the V2 context
* hook's upsert (replace vs. append) actually finds the existing prompt and
* does not append a duplicate on every dispatch. Exported for a regression
* test that guards this invariant against SYSTEM_PROMPT edits. */
export const SYSTEM_MARKER = "ACP TOOLS (billion-context)"

interface ModelRef {
id?: string
Expand Down Expand Up @@ -309,10 +319,10 @@ async function setupV2(ctx: PluginSetupContext): Promise<() => void> {

await ctx.tool.transform((tools) => {
const opts = { codemode: false, permission: "allow" }
tools.add({ ...makeCompressTool(runtime), options: opts })
tools.add({ ...makeDecompressTool(runtime), options: opts })
tools.add({ ...makeSearchTool(runtime), options: opts })
tools.add({ ...makeStatusTool(runtime), options: opts })
tools.add({ ...makeV2CompressTool(runtime), options: opts })
tools.add({ ...makeV2DecompressTool(runtime), options: opts })
tools.add({ ...makeV2SearchTool(runtime), options: opts })
tools.add({ ...makeV2StatusTool(runtime), options: opts })
})

await ctx.session.hook("context", async (event) => {
Expand Down
77 changes: 77 additions & 0 deletions packages/billion-context-opencode/src/v2-tools.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,77 @@
import { z } from "zod"
import {
makeCompressTool,
makeDecompressTool,
makeSearchTool,
makeStatusTool,
type AcpRuntime,
type ToolContext,
type ToolDef,
} from "@bili/core"

/** Structural subset of opencode V2's ToolContext (see @opencode-ai/plugin).
* `id` is the tool-call id; V1's `callID` maps onto it. Deliberately NOT
* imported from the SDK so the built artifact stays runtime-dependency-free
* (AGENTS.md §2.3 — the dual-shape mechanism). */
export interface V2ToolContext {
sessionID: string
agent?: string
messageID?: string
id?: string
progress?: (update: unknown) => Promise<void>
}

/** opencode V2 tool shape (`Info`): { name, description, input, execute }. */
export interface V2ToolInfo {
name: string
description: string
input: Record<string, unknown>
execute(input: Record<string, unknown>, ctx: V2ToolContext): Promise<{ content: string }>
}

/** opencode V2 does not provide directory/worktree, but the V1 ToolContext
* types them as required strings — pass empty defaults (the bili_* tools key
* all persistence off sessionID and never read them). */
function toV1Context(ctx: V2ToolContext): ToolContext {
return {
sessionID: ctx.sessionID,
messageID: ctx.messageID ?? "",
callID: ctx.id,
agent: ctx.agent ?? "",
directory: "",
worktree: "",
}
}

/** Wrap a V1 ToolDef into opencode V2's tool shape. The JSON Schema is derived
* from the tool's zod `args` via `z.toJSONSchema()` (zod >= 4) so the V2 schema
* cannot drift from the V1 definitions; execute maps the V2 context onto the
* V1 ToolContext and unwraps the V1 result (`string | { output, metadata }`)
* into V2's `{ content }` envelope. */
function toV2Tool(name: string, tool: ToolDef): V2ToolInfo {
return {
name,
description: tool.description,
input: z.toJSONSchema(z.object(tool.args)) as Record<string, unknown>,
async execute(input, ctx) {
const result = await tool.execute(input, toV1Context(ctx))
return { content: typeof result === "string" ? result : result.output }
},
}
}

export function makeV2CompressTool(runtime: AcpRuntime): V2ToolInfo {
return toV2Tool("bili_compress", makeCompressTool(runtime))
}

export function makeV2DecompressTool(runtime: AcpRuntime): V2ToolInfo {
return toV2Tool("bili_decompress", makeDecompressTool(runtime))
}

export function makeV2SearchTool(runtime: AcpRuntime): V2ToolInfo {
return toV2Tool("bili_search", makeSearchTool(runtime))
}

export function makeV2StatusTool(runtime: AcpRuntime): V2ToolInfo {
return toV2Tool("bili_status", makeStatusTool(runtime))
}
57 changes: 57 additions & 0 deletions packages/billion-context-opencode/tests/v2-tools.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,57 @@
import { test } from "node:test"
import assert from "node:assert/strict"
import { AcpRuntime, SYSTEM_PROMPT } from "@bili/core"
import {
makeV2CompressTool,
makeV2DecompressTool,
makeV2SearchTool,
makeV2StatusTool,
} from "../src/v2-tools.js"
import { SYSTEM_MARKER } from "../src/index.js"

const runtime = new AcpRuntime({})
const allTools = [
makeV2CompressTool(runtime),
makeV2DecompressTool(runtime),
makeV2SearchTool(runtime),
makeV2StatusTool(runtime),
]
const expectedNames = ["bili_compress", "bili_decompress", "bili_search", "bili_status"]

test("V2 tools expose the AI-SDK shape { name, description, input(JSON schema), execute }", () => {
for (let i = 0; i < allTools.length; i++) {
const t = allTools[i]!
assert.equal(t.name, expectedNames[i], `tool ${i} name`)
assert.equal(typeof t.description, "string")
assert.ok(t.description.length > 0, `${t.name}: description non-empty`)
assert.equal(t.input.type, "object", `${t.name}: input.type === "object"`)
assert.ok(t.input.properties && typeof t.input.properties === "object", `${t.name}: input has properties`)
assert.equal(typeof t.execute, "function", `${t.name}: execute is a function`)
}
})

test("V2 compress input schema mirrors the V1 zod args (no drift)", () => {
const props = makeV2CompressTool(runtime).input.properties as Record<string, { type?: string; items?: { properties?: Record<string, unknown> } }>
assert.equal(props.content?.type, "array")
const itemProps = props.content!.items!.properties!
for (const key of ["startId", "endId", "summary"]) {
assert.ok(itemProps[key], `content item has ${key}`)
}
assert.deepEqual(makeV2CompressTool(runtime).input.required, ["content"])
})

test("V2 execute maps the V2 ctx onto the V1 ctx and unwraps the result into { content }", async () => {
const res = await makeV2StatusTool(runtime).execute(
{},
{ sessionID: "v2-tools-test", id: "call-42", agent: "a", messageID: "m1" },
)
assert.equal(typeof res.content, "string")
assert.ok(res.content.length > 0)
})

test("SYSTEM_MARKER matches the SYSTEM_PROMPT header so the V2 upsert stays idempotent", () => {
assert.ok(
SYSTEM_PROMPT.includes(SYSTEM_MARKER),
`SYSTEM_MARKER "${SYSTEM_MARKER}" must appear in SYSTEM_PROMPT or the V2 hook appends a duplicate prompt every dispatch`,
)
})
Loading