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
14 changes: 7 additions & 7 deletions bun.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

6 changes: 3 additions & 3 deletions packages/coder-tui/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -25,12 +25,12 @@
"prepublishOnly": "bun run clean && bun run build"
},
"dependencies": {
"@mariozechner/pi-coding-agent": "^0.67.68",
"@mariozechner/pi-tui": "^0.67.68",
"@mariozechner/pi-coding-agent": "^0.68.1",
"@mariozechner/pi-tui": "^0.68.1",
"@sinclair/typebox": "^0.34.48"
},
"devDependencies": {
"@mariozechner/pi-ai": "^0.67.68",
"@mariozechner/pi-ai": "^0.68.1",
"@types/bun": "^1.3.9",
"bun-types": "^1.3.9",
"typescript": "^5.9.0"
Expand Down
26 changes: 14 additions & 12 deletions packages/coder-tui/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import { setNativeRemoteExtensionContext } from './native-remote-ui-context.ts';
import { handleRemoteUiRequest } from './remote-ui-handler.ts';
import { buildInboundRpcPromptText, getInboundRpcDeliverAs } from './inbound-rpc.ts';
import { applyCoderAuthHeaders, getCoderAuthCurlArgs } from './auth.ts';
import { selectSubAgentToolNames } from './subagent-tool-selection.ts';
import type {
HubAction,
HubResponse,
Expand Down Expand Up @@ -1755,13 +1756,7 @@ async function runSubAgent(
const { piSdk, piAi } = await loadPiSdk();
// Runtime-resolved dynamic imports — exact types unavailable statically
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const {
createAgentSession,
DefaultResourceLoader,
SessionManager,
createCodingTools,
createReadOnlyTools,
} = piSdk as any;
const { createAgentSession, DefaultResourceLoader, getAgentDir, SessionManager } = piSdk as any;
// eslint-disable-next-line @typescript-eslint/no-explicit-any
const { getModel } = piAi as any;

Expand All @@ -1783,13 +1778,16 @@ async function runSubAgent(
// Sub-agents get Hub tools (memory, context7, etc.) via extensionFactories
// so they work in both driver and TUI mode.
const hubTools = agentConfig.hubTools ?? [];
const cwd = process.cwd();
const agentDir = getAgentDir();

// Resource loader — no extensions (prevents recursive task tool registration),
// no skills, agent's system prompt injected directly.
// Hub tools are injected via extensionFactories so sub-agents can use
// memory_recall, context7_search, etc.
const subLoader = new DefaultResourceLoader({
cwd: process.cwd(),
cwd,
agentDir,
noExtensions: true,
extensionFactories:
hubTools.length > 0
Expand All @@ -1808,9 +1806,12 @@ async function runSubAgent(
});
await subLoader.reload();

// Select tools based on readOnly flag
const cwd = process.cwd();
const tools = agentConfig.readOnly ? createReadOnlyTools(cwd) : createCodingTools(cwd);
// Pi v0.68.x uses a name allowlist for both built-in and extension/custom tools.
const builtInToolNames = selectSubAgentToolNames(agentConfig);
const hubToolNames = hubTools
.map((tool) => (typeof tool.name === 'string' ? tool.name.trim() : ''))
.filter((name): name is string => name.length > 0);
const tools = Array.from(new Set([...builtInToolNames, ...hubToolNames]));

const { session } = await createAgentSession({
// subModel is already untyped (from dynamic import) — createAgentSession is also dynamically imported
Expand All @@ -1824,7 +1825,8 @@ async function runSubAgent(
| 'xhigh',
tools,
resourceLoader: subLoader,
sessionManager: SessionManager.inMemory('/tmp'),
// Pi now tracks cwd per session, so bind in-memory sub-agents to the actual repo cwd.
sessionManager: SessionManager.inMemory(cwd),
});
await session.bindExtensions({});

Expand Down
2 changes: 2 additions & 0 deletions packages/coder-tui/src/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ export type HubAction =
export interface AgentDefinition {
name: string;
displayName?: string;
source?: 'builtin' | 'custom';
description: string;
systemPrompt: string;
model?: string;
Expand All @@ -84,6 +85,7 @@ export interface AgentDefinition {
readOnly?: boolean;
hubTools?: HubToolDefinition[];
capabilities?: string[];
strictToolSelection?: boolean;
status?: 'available' | 'busy' | 'offline';
}

Expand Down
33 changes: 33 additions & 0 deletions packages/coder-tui/src/subagent-tool-selection.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
import type { AgentDefinition } from './protocol.ts';
Comment thread
rblalock marked this conversation as resolved.

const READ_ONLY_TOOL_NAMES = ['read', 'grep', 'find', 'ls'] as const;
const CODING_TOOL_NAMES = ['read', 'bash', 'edit', 'write'] as const;

function normalizeToolName(name: string): string {
return name.trim().toLowerCase();
}

export function selectSubAgentToolNames(agentConfig: AgentDefinition): string[] {
const declared = new Set(
(agentConfig.tools ?? [])
.filter((name): name is string => typeof name === 'string' && name.trim().length > 0)
.map(normalizeToolName)
);
const needsBash = declared.has('bash');
const baseToolNames =
agentConfig.readOnly && !needsBash ? READ_ONLY_TOOL_NAMES : CODING_TOOL_NAMES;
const allowLegacyFallback =
agentConfig.strictToolSelection !== true && agentConfig.source === 'builtin';

if (declared.size === 0) {
return allowLegacyFallback ? [...baseToolNames] : [];
}

const filtered = baseToolNames.filter((toolName) => declared.has(toolName));

if (filtered.length > 0) {
return filtered;
}

return allowLegacyFallback ? [...baseToolNames] : [];
}
72 changes: 72 additions & 0 deletions packages/coder-tui/test/subagent-tool-selection.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
import { describe, expect, it } from 'bun:test';
import type { AgentDefinition } from '../src/protocol.ts';
import { selectSubAgentToolNames } from '../src/subagent-tool-selection.ts';
Comment thread
rblalock marked this conversation as resolved.

function createAgent(overrides?: Partial<AgentDefinition>): AgentDefinition {
return {
name: 'runner',
source: 'builtin',
description: 'test',
systemPrompt: 'test',
tools: ['read', 'grep', 'find', 'ls'],
readOnly: true,
...overrides,
};
}

describe('selectSubAgentToolNames', () => {
it('uses the read-only baseline for read-only agents without bash', () => {
expect(selectSubAgentToolNames(createAgent())).toEqual(['read', 'grep', 'find', 'ls']);
});

it('switches to the coding baseline when a read-only agent declares bash', () => {
expect(
selectSubAgentToolNames(createAgent({ tools: ['read', 'bash', 'ls'], readOnly: true }))
).toEqual(['read', 'bash']);
});

it('keeps the legacy fallback for non-strict agents when declarations do not match', () => {
expect(selectSubAgentToolNames(createAgent({ tools: ['totally_unknown_tool'] }))).toEqual([
'read',
'grep',
'find',
'ls',
]);
});

it('keeps custom agents deny-by-default when declarations do not match', () => {
expect(
selectSubAgentToolNames(
createAgent({
name: 'qa-review',
source: 'custom',
tools: ['totally_unknown_tool'],
})
)
).toEqual([]);
});

it('does not widen tool access for strict custom agents when names do not match', () => {
expect(
selectSubAgentToolNames(
createAgent({
name: 'code-review',
source: 'custom',
tools: ['totally_unknown_tool'],
strictToolSelection: true,
})
)
).toEqual([]);
});

it('returns the coding baseline for non-read-only agents without declared Pi tools', () => {
expect(
selectSubAgentToolNames(
createAgent({
readOnly: false,
tools: undefined,
})
)
).toEqual(['read', 'bash', 'edit', 'write']);
});
});
12 changes: 12 additions & 0 deletions packages/core/src/services/coder/protocol.ts
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,12 @@ export const AgentDefinitionSchema = z
.string()
.optional()
.describe('Human-friendly name shown in UIs; defaults to name if omitted.'),
source: z
.enum(['builtin', 'custom'])
.optional()
.describe(
'Whether this agent is part of the built-in roster or a custom user-defined agent.'
),
description: z.string().describe('Summary of the agent role and capabilities.'),
systemPrompt: z
.string()
Expand Down Expand Up @@ -99,6 +105,12 @@ export const AgentDefinitionSchema = z
.array(z.string())
.optional()
.describe('Capability tags advertising what this agent can do (e.g. "code", "review").'),
strictToolSelection: z
.boolean()
.optional()
.describe(
'When true, unknown or unmatched tool names should not widen the effective Pi tool allowlist.'
),
status: z
.enum(['available', 'busy', 'offline'])
.optional()
Expand Down
Loading