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
28 changes: 28 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
name: CI

on:
pull_request:
push:
branches:
- main

permissions:
contents: read

concurrency:
group: ci-${{ github.workflow }}-${{ github.ref }}
cancel-in-progress: true

jobs:
check:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v7
with:
persist-credentials: false
- uses: actions/setup-node@v6
with:
node-version: 24
- uses: oven-sh/setup-bun@v2
- run: bun install --frozen-lockfile
- run: bun run check
47 changes: 46 additions & 1 deletion extensions/agent-team/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,14 +3,21 @@ import test from "node:test";
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
import agentTeamExtension from "./index.ts";

test("agent-team propagates invalid start input as an execute error", async () => {
function captureTool(extra: Record<string, unknown> = {}): any {
let tool: any;
agentTeamExtension({
on() {},
registerTool(value: unknown) {
tool = value;
},
...extra,
} as unknown as ExtensionAPI);
assert.ok(tool);
return tool;
}

test("agent-team propagates invalid start input as an execute error", async () => {
const tool = captureTool();

await assert.rejects(
() =>
Expand All @@ -24,3 +31,41 @@ test("agent-team propagates invalid start input as an execute error", async () =
/topic is required for start/,
);
});

test("agent-team exposes its supported read-only tools in the tool schema", () => {
const tool = captureTool();
const schema = JSON.stringify(tool.parameters);
for (const name of ["read", "grep", "find", "ls", "web_search", "web_fetch"]) {
assert.match(schema, new RegExp(`"${name}"`));
}
assert.doesNotMatch(schema, /"bash"/);
assert.doesNotMatch(schema, /"astrolabe"/);
});

test("agent-team reports the supported tool set when direct callers bypass schema validation", async () => {
const tool = captureTool({
getAllTools() {
return [{ name: "read" }, { name: "bash" }];
},
});

await assert.rejects(
() =>
tool.execute(
"test-call",
{
action: "start",
topic: "Review this change",
members: [
{ name: "a", role: "reviewer" },
{ name: "b", role: "skeptic" },
],
tools: ["bash"],
},
undefined,
undefined,
{ ui: { setStatus() {} } },
),
/supported read-only tools \(read, grep, find, ls, web_search, web_fetch\); unsupported: bash/,
);
});
19 changes: 12 additions & 7 deletions extensions/agent-team/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { randomUUID } from "node:crypto";
import { access } from "node:fs/promises";
import { homedir } from "node:os";
import { isAbsolute, join, resolve } from "node:path";
import { Type } from "@earendil-works/pi-ai";
import { StringEnum, Type } from "@earendil-works/pi-ai";
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
import { Text } from "@earendil-works/pi-tui";
import { agentTeamTaskRoot, createPiAgentFactory } from "./pi-runner.ts";
Expand All @@ -18,7 +18,8 @@ import {

const TOOL_NAME = "agent_team";
const STATUS_KEY = "agent-team";
const READ_ONLY_TOOLS = new Set(["read", "grep", "find", "ls", "web_search", "web_fetch"]);
const READ_ONLY_TOOL_NAMES = ["read", "grep", "find", "ls", "web_search", "web_fetch"] as const;
const READ_ONLY_TOOLS = new Set<string>(READ_ONLY_TOOL_NAMES);

interface AgentTeamToolParams {
action: "start" | "list" | "check" | "answer" | "stop";
Expand All @@ -43,10 +44,13 @@ interface AgentTeamToolParams {
}

function selectTools(requested: string[] | undefined, available: Set<string>): string[] {
if (requested?.some((tool) => !READ_ONLY_TOOLS.has(tool))) {
throw new Error("agent-team only accepts known read-only tools");
const unsupported = requested?.filter((tool) => !READ_ONLY_TOOLS.has(tool)) ?? [];
if (unsupported.length > 0) {
throw new Error(
`agent-team only accepts supported read-only tools (${READ_ONLY_TOOL_NAMES.join(", ")}); unsupported: ${unsupported.join(", ")}`,
);
}
return (requested ?? [...READ_ONLY_TOOLS]).filter((tool) => available.has(tool));
return (requested ?? [...READ_ONLY_TOOL_NAMES]).filter((tool) => available.has(tool));
}

async function resolveSkill(spec: string, cwd: string): Promise<string> {
Expand Down Expand Up @@ -198,8 +202,9 @@ export default function agentTeamExtension(pi: ExtensionAPI): void {
answer: Type.Optional(Type.String({ description: "User direction for a waiting team" })),
thinking: Type.Optional(Type.String({ description: "Thinking level for child Pi agents" })),
tools: Type.Optional(
Type.Array(Type.String(), {
description: "Read-only tools available to team members",
Type.Array(StringEnum(READ_ONLY_TOOL_NAMES), {
description:
"Optional allowlist of supported read-only tools for team members. Omit to use every supported read-only tool that is currently available.",
}),
),
turnTimeoutMs: Type.Optional(
Expand Down
10 changes: 5 additions & 5 deletions extensions/astrolabe/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ Astrolabeがコードで強制するのは、continuationの有効性、対象
- `locate`は編集意図に含まれる`symbols`または`terms`から宣言ノードを順位付けします。Tree-sitterの構造・本文signalと、利用可能なLSPの`workspace/symbol`を独立したcandidate generatorとして並行して使い、同じconcrete syntax nodeを支持するevidenceは加算してconfidenceを上げます。LSPが利用できなければstructural/textual signalだけで同じ処理を続行します。完全一致symbolで明確に首位かつ本文が6,000 bytes以下なら`mode: "source"`として本文も返し、それ以外は`mode: "cards"`としてシグネチャ、親宣言、flow、範囲、continuationを返します。
- `search`は構文形状による補助探索です。関数・呼出し・importを検索し、`locate`で対象を特定できない調査に使います。
- `inspect`は`path`でoutlineを取得するか、continuationで選んだ構文ノードのsourceを取得します。
- `inspect_many`は同一ファイルの複数continuationを一度にsourceまで取得し、`replace_many`用のテンプレートを返します
- `inspect_many`は複数continuationをファイルをまたいで並列にsourceまで取得します。対象がすべて同一ファイルなら`replace_many`用のテンプレートも返し、複数ファイルにまたがる場合は読み取り結果だけを返します
- `replace`は有効なcontinuationと完全な`replacement`を受け、現在のsource hash、ノード型・範囲・親文脈を再検証してから保存します。
- `replace_many`は同一ファイル内の複数continuationを全件検証し、置換後の構文検査に成功した場合だけatomicに保存します。
- `rename`は宣言continuationと`newName`を受け、LSPの`textDocument/rename`に意味論的なWorkspaceEditを生成させます。AstrolabeはWorkspaceEditを即適用せず、対象ファイルのstaleness、範囲重複、対応言語、置換後の構文を検証してからcommitします。
Expand Down Expand Up @@ -74,13 +74,13 @@ WorkspaceEditは既存のAstrolabe対応ソースへのtext editだけを受理

- `locate(mode: "source")` → 通常はそのまま`replace`。同じnodeを再度`inspect`しません。
- `locate(mode: "cards")` → cardだけでreplacementが決まるなら直接`replace`。本文が必要なら選んだcardだけ`inspect`します。
- 同一ファイルの複数cardで本文が必要 → `inspect_many``replace_many`。
- 複数cardで本文が必要 → `inspect_many`。同一ファイルなら`replace_many`を次手として返し、複数ファイルなら読み取りだけをbatchします
- シンボル自体のrename → `locate` → `rename`。referencesを手作業で`replace_many`しません。
- `locate`で対象を絞れない → `search`またはoutline `inspect`へ広げます。
- `locate`で対象を絞れない → 関数・呼出し・importなら`search`、より広い構造確認ならoutline `inspect`へ広げます。任意の文字列検索は通常のtext retrievalを使います

## ハンドルと位置

continuationは短命かつセッション限定です。ハンドルはsource hash、ノード型、親宣言、祖先型、field、前後兄弟、周辺sourceを保持し、ファイル変更後も同一ノードを一意に再同定できる場合だけ通常のnode replacementを継続します。曖昧なら`stale_node`で拒否します。
continuationはセッション限定です。内部のhandle cacheからLRU evictionされてもcontinuationが保持するsnapshotから再活性化するため、cache pressureだけでは失効しません。ハンドルはsource hash、ノード型、親宣言、祖先型、field、前後兄弟、周辺sourceを保持し、ファイル変更後も同一ノードを一意に再同定できる場合だけ通常のnode replacementを継続します。曖昧なら`stale_node`で拒否します。明示的に無効化されたcontinuationや終了済みセッションのcontinuationは`invalid_continuation`です

`web-tree-sitter`の公開インデックスと`Point`はこのバインディングのUTF-16 JavaScript文字列位置として扱います。ハンドルには別途UTF-8バイト範囲も保存します。サロゲートペアやUTF-8コードポイントの途中は位置として受け付けません。

Expand Down Expand Up @@ -108,7 +108,7 @@ Astrolabeのスコープは編集です。LSPやTree-sitterによる探索は、
## 状態コード

- `stale_node`: continuationの対象を現在のファイルから一意に再同定できません。
- `invalid_continuation`: continuationが失効したか変更されています
- `invalid_continuation`: continuationが明示的に無効化されたか、現在のセッションに存在しません。通常のhandle cache evictionだけでは発生しません
- `lsp_unavailable`: 対応language serverを起動できません。
- `rename_unavailable`: language serverまたは対象位置がrenameを受け付けません。
- `stale_workspace_edit`: LSPがWorkspaceEditを生成した後に対象ファイルが変化しました。semantic operationを再実行します。
Expand Down
42 changes: 41 additions & 1 deletion extensions/astrolabe/index.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,10 +29,16 @@ interface SyntaxResponse {
flow: { calls: string[]; branches: number; returns: number; throws: number; awaits: number };
score: number;
}>;
sources?: Array<{
continuation: { token: string };
path: string;
type: string;
source: string;
}>;
};
handles?: Array<{ continuation: { token: string }; capabilities: string[] }>;
next?: Array<Record<string, unknown>>;
error?: { code: string };
error?: { code: string; message?: string };
}

interface ToolResult {
Expand Down Expand Up @@ -115,6 +121,39 @@ test("inspect path returns an executable next action and continuation source loo
assert.equal(sourcedResponse.next?.[0]?.action, "replace");
});

test("inspect_many reads selected continuations across files without proposing cross-file mutation", async () => {
const dir = await mkdtemp(join(tmpdir(), "astrolabe-index-"));
await writeFile(join(dir, "first.ts"), "function first() { return 1; }\n");
await writeFile(join(dir, "second.ts"), "function second() { return 2; }\n");
const tool = setup(dir);

const firstOutline = responseOf(
await call(tool, dir, { action: "inspect", path: "first.ts", detail: "outline" }),
);
const secondOutline = responseOf(
await call(tool, dir, { action: "inspect", path: "second.ts", detail: "outline" }),
);
const first = firstOutline.next?.[0] as { continuation?: { token: string } } | undefined;
const second = secondOutline.next?.[0] as { continuation?: { token: string } } | undefined;
assert.ok(first?.continuation);
assert.ok(second?.continuation);

const inspected = responseOf(
await call(tool, dir, {
action: "inspect_many",
targets: [
{ continuation: first.continuation },
{ continuation: second.continuation },
],
}),
);
assert.equal(inspected.ok, true);
assert.equal(inspected.data?.sources?.length, 2);
assert.match(inspected.data?.sources?.[0]?.source ?? "", /function first/);
assert.match(inspected.data?.sources?.[1]?.source ?? "", /function second/);
assert.equal(inspected.next, undefined);
});

test("locate returns a ranked source-inspected candidate usable for direct replacement", async () => {
const dir = await mkdtemp(join(tmpdir(), "astrolabe-index-"));
const path = join(dir, "sample.ts");
Expand Down Expand Up @@ -213,6 +252,7 @@ test("locate rejects missing hints and returns no-candidate failures", async ()
});
assert.equal(noCandidates.ok, false);
assert.equal(noCandidates.error?.code, "no_candidates");
assert.match(noCandidates.error?.message ?? "", /locate resolves declarations/);
});

test("directory search returns continuations usable for direct replacement", async () => {
Expand Down
Loading