Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
471920d
feat: add progressive skill disclosure with listing and on-demand ski…
ital0 Jul 24, 2026
e2ae12c
feat: convert spontaneous widget instructions into seeded default skills
ital0 Jul 24, 2026
898956a
feat: deliver skills to acp agents via capability meta with prompt fa…
ital0 Jul 24, 2026
0d03c6f
test: add widget regression eval scenarios for disclosure parity
ital0 Jul 24, 2026
f4fc677
fix: seed widget skills unpinned to keep composer chips task-only
ital0 Jul 24, 2026
7f6c8ff
refactor: drop dead citation and document-result instruction exports
ital0 Jul 24, 2026
af5c626
Merge branch 'main' into italomenezes/thu-736-convert-chat-widgets-in…
ital0 Jul 24, 2026
b97d8df
fix: skip forced skill instructions already carried by acp session di…
ital0 Jul 24, 2026
9683b47
fix: regenerate bun.lock for oauth2-mock-server 9.1.0
ital0 Jul 24, 2026
289c495
Merge branch 'main' into italomenezes/thu-736-convert-chat-widgets-in…
ital0 Jul 24, 2026
6975d15
feat: lock widget default skills to enable and disable only
ital0 Jul 24, 2026
336861a
fix: enforce widget skill lock on pin path and reconcile contracts pa…
ital0 Jul 24, 2026
5cbd66e
Merge remote-tracking branch 'origin/main' into italomenezes/thu-736-…
ital0 Jul 28, 2026
5c61e75
Merge remote-tracking branch 'origin/main' into italomenezes/thu-736-…
ital0 Jul 28, 2026
b7f861d
fix: relative reorder guard, widget unpin, and full skill fallback fo…
ital0 Jul 28, 2026
491599b
fix: limit non-tool model skill fallback to widget contracts
ital0 Jul 28, 2026
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
58 changes: 55 additions & 3 deletions cli/src/acp/harness-agent.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,13 @@ import type {
} from '@agentclientprotocol/sdk'
import type { StopReason as PiStopReason, AssistantMessage } from '@earendil-works/pi-ai'
import { InMemorySessionRepo } from '@earendil-works/pi-agent-core'
import type { AgentHarnessEvent, Session as PiSession, ToolCallEvent, ToolCallResult } from '@earendil-works/pi-agent-core'
import type {
AgentHarnessEvent,
Session as PiSession,
ToolCallEvent,
ToolCallResult,
} from '@earendil-works/pi-agent-core'
import { buildWireSkillsMeta, skillsCapabilityMeta, type SkillDefinition } from '../../../shared/agent-core/skills.ts'
import { createHarnessAgent } from './harness-agent.ts'
import type { BuildServeHarness } from './harness-agent.ts'
import type { SessionStore } from './session-store.ts'
Expand Down Expand Up @@ -170,9 +176,47 @@ describe('createHarnessAgent (ACP server)', () => {
expect(init.protocolVersion).toBe(PROTOCOL_VERSION)
expect(init.agentCapabilities?.loadSession).toBe(false)
expect(init.agentCapabilities?.sessionCapabilities?.resume).toBeDefined()
expect(init.agentCapabilities?._meta).toEqual(skillsCapabilityMeta)
expect(init.agentInfo?.name).toBe('thunderbolt')
})

test('session/new and session/resume pass wire-delivered skills into harness config', async () => {
const capturedSkills: Array<readonly SkillDefinition[]> = []
const capturingBuilder: BuildServeHarness = async (harnessConfig) => {
capturedSkills.push(harnessConfig.skills ?? [])
return {
harness: {
subscribe: () => () => {},
registerToolCallGate: () => {},
prompt: async () => assistantMessage('stop'),
waitForIdle: async () => {},
abort: async () => {},
},
dispose: async () => {},
}
}
const skills: SkillDefinition[] = [
{
name: 'daily-brief',
description: 'Build a concise daily rundown.',
instruction: 'Gather current weather and calendar details.',
},
]
const meta = buildWireSkillsMeta(skills)
const { client } = connectPair(capturingBuilder)
await client.initialize({ protocolVersion: PROTOCOL_VERSION, clientCapabilities: {} })

await client.newSession({ cwd: '/', mcpServers: [], _meta: meta })
await client.resumeSession({
sessionId: '11111111-1111-4111-8111-111111111111',
cwd: '/',
mcpServers: [],
_meta: meta,
})

expect(capturedSkills).toEqual([skills, skills])
})

test('session/resume opens the stored session by id and injects it into the harness (no replay)', async () => {
const store = fakeStore()
const injected: PiSession[] = []
Expand Down Expand Up @@ -336,7 +380,7 @@ describe('createHarnessAgent (ACP server)', () => {
expect(decisions).toEqual([undefined, undefined, undefined])
})

test('webfetch is auto-allowed without prompting while bash stays gated', async () => {
test('webfetch and skill are auto-allowed without prompting while bash stays gated', async () => {
const decisions: Array<ToolCallResult | undefined> = []
const webBuilder: BuildServeHarness = async () => {
let gate: ((event: ToolCallEvent) => Promise<ToolCallResult | undefined>) | null = null
Expand All @@ -355,6 +399,14 @@ describe('createHarnessAgent (ACP server)', () => {
input: { url: 'https://example.com' },
}),
)
decisions.push(
await gate?.({
type: 'tool_call',
toolCallId: 'skill',
toolName: 'skill',
input: { name: 'daily-brief' },
}),
)
decisions.push(
await gate?.({ type: 'tool_call', toolCallId: 'shell', toolName: 'bash', input: { command: 'curl x' } }),
)
Expand All @@ -372,7 +424,7 @@ describe('createHarnessAgent (ACP server)', () => {
const { sessionId } = await client.newSession({ cwd: '/', mcpServers: [] })
await client.prompt({ sessionId, prompt: [{ type: 'text', text: 'web' }] })

expect(decisions).toEqual([undefined, { block: true, reason: 'user rejected bash' }])
expect(decisions).toEqual([undefined, undefined, { block: true, reason: 'user rejected bash' }])
expect(permissions.map((request) => request.toolCall.toolCallId)).toEqual(['shell'])
})

Expand Down
16 changes: 10 additions & 6 deletions cli/src/acp/harness-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,7 @@ import type {
} from '@earendil-works/pi-agent-core'
import type { AssistantMessage } from '@earendil-works/pi-ai'
import { isReadOnlyAgentTool, resolveToolPermission } from '../../../shared/agent-tool-permissions.ts'
import { readWireSkills, skillsCapabilityMeta, type SkillDefinition } from '../../../shared/agent-core/skills.ts'
import { cliVersion } from '../cli.ts'
import { buildHarness } from '../agent/harness.ts'
import type { HarnessConfig, ServeConfig } from '../agent/types.ts'
Expand Down Expand Up @@ -176,7 +177,7 @@ const attachAcpPermissionGate = (
const sessionAllowed = new Set<string>()

harness.registerToolCallGate(async ({ toolCallId, toolName, input }) => {
if (toolName === 'webfetch') return undefined
if (toolName === 'webfetch' || toolName === 'skill') return undefined
if (isReadOnlyAgentTool(toolName)) {
const path =
typeof input === 'object' && input !== null && 'path' in input && typeof input.path === 'string'
Expand Down Expand Up @@ -263,12 +264,13 @@ export const createHarnessAgent = (
loadSession: false,
sessionCapabilities: { resume: {} },
promptCapabilities: { image: false, audio: false, embeddedContext: false },
_meta: skillsCapabilityMeta,
},
authMethods: [],
})

/** Per-session harness config rooted at server-owned launch directory. */
const harnessConfigFor = (workspaceRoot: string): HarnessConfig => ({
const harnessConfigFor = (workspaceRoot: string, skills: readonly SkillDefinition[]): HarnessConfig => ({
model: config.model,
cwd: workspaceRoot,
workspaceRoot,
Expand All @@ -278,6 +280,7 @@ export const createHarnessAgent = (
baseUrl: config.baseUrl,
apiKey: config.apiKey,
announceModel: true,
skills,
})

/** Build the harness on `session`, wire its run events + permission gate to the
Expand All @@ -287,9 +290,10 @@ export const createHarnessAgent = (
sessionId: SessionId,
workspaceRoot: string,
session: PiSession,
skills: readonly SkillDefinition[],
phase: string,
): Promise<void> => {
const { harness, dispose } = await buildServeHarness(harnessConfigFor(workspaceRoot), session)
const { harness, dispose } = await buildServeHarness(harnessConfigFor(workspaceRoot, skills), session)

// If the client vanished while the harness was being built, the cleanup
// microtask already ran against a map without this session — dispose now so
Expand Down Expand Up @@ -321,11 +325,11 @@ export const createHarnessAgent = (
}
}

const newSession = async (_params: NewSessionRequest): Promise<NewSessionResponse> => {
const newSession = async (params: NewSessionRequest): Promise<NewSessionResponse> => {
const sessionId = crypto.randomUUID()
const workspaceRoot = await trustedWorkspace
const session = await store.createSession(sessionId, workspaceRoot)
await activate(sessionId, workspaceRoot, session, 'session/new')
await activate(sessionId, workspaceRoot, session, readWireSkills(params._meta), 'session/new')
return { sessionId }
}

Expand All @@ -343,7 +347,7 @@ export const createHarnessAgent = (
}
const workspaceRoot = await trustedWorkspace
const session = await store.openSession(params.sessionId, workspaceRoot)
await activate(params.sessionId, workspaceRoot, session, 'session/resume')
await activate(params.sessionId, workspaceRoot, session, readWireSkills(params._meta), 'session/resume')
return {}
}

Expand Down
10 changes: 10 additions & 0 deletions cli/src/agent/harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { describe, expect, test } from 'bun:test'
import type { SkillDefinition } from '../../../shared/agent-core/skills.ts'
import { createHarnessTools } from './harness.ts'

describe('createHarnessTools', () => {
Expand All @@ -21,4 +22,13 @@ describe('createHarnessTools', () => {
'webfetch',
])
})

test('registers skill tool only when session provides skills', () => {
const skills: SkillDefinition[] = [
{ name: 'daily-brief', description: 'Build a daily rundown.', instruction: 'Gather current details.' },
]

expect(createHarnessTools({ cwd: '/work' }).map((tool) => tool.name)).not.toContain('skill')
expect(createHarnessTools({ cwd: '/work', skills }).map((tool) => tool.name)).toContain('skill')
})
})
7 changes: 5 additions & 2 deletions cli/src/agent/harness.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,17 +15,19 @@ import type { AgentTool, Session } from '@earendil-works/pi-agent-core'
import { NodeExecutionEnv } from '@earendil-works/pi-agent-core/node'
import { createBashTool, createEditTool, createReadTool, createWriteTool } from '@earendil-works/pi-coding-agent'
import { configureNativeWebSearch, resolveModel } from './model.ts'
import { createSkillTool } from './skill-tool.ts'
import { buildSystemPrompt } from './system-prompt.ts'
import type { HarnessBundle, HarnessConfig } from './types.ts'
import { createWorkspaceTools } from './workspace-jail.ts'
import { createWebFetchTool } from './webfetch.ts'

/** Build complete toolset shared by local CLI and ACP-served harnesses. */
export const createHarnessTools = (config: Pick<HarnessConfig, 'cwd' | 'workspaceRoot'>): AgentTool[] => {
export const createHarnessTools = (config: Pick<HarnessConfig, 'cwd' | 'workspaceRoot' | 'skills'>): AgentTool[] => {
const codingTools = config.workspaceRoot
? createWorkspaceTools(config.workspaceRoot)
: [createBashTool(config.cwd), createReadTool(config.cwd), createWriteTool(config.cwd), createEditTool(config.cwd)]
return [...codingTools, createWebFetchTool()]
const skillTools = config.skills?.length ? [createSkillTool(config.skills)] : []
return [...codingTools, createWebFetchTool(), ...skillTools]
}

/**
Expand Down Expand Up @@ -68,6 +70,7 @@ export const buildHarness = async (config: HarnessConfig, session?: Session): Pr
cwd: config.cwd,
modelId: config.announceModel ? config.model : undefined,
bashEnabled: tools.some((tool) => tool.name === 'bash'),
skills: config.skills,
}),
})
harness.on('before_provider_payload', ({ model: requestModel, payload }) => ({
Expand Down
34 changes: 34 additions & 0 deletions cli/src/agent/skill-tool.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { describe, expect, test } from 'bun:test'
import { buildWireSkillsMeta, readWireSkills, type SkillDefinition } from '../../../shared/agent-core/skills.ts'
import { createSkillTool } from './skill-tool.ts'
import { buildSystemPrompt } from './system-prompt.ts'

const skills: SkillDefinition[] = [
{
name: 'daily-brief',
description: 'Build a concise daily rundown.',
instruction: 'Gather current weather and calendar details.',
},
]

describe('wire-delivered CLI skills', () => {
test('lists compact metadata and resolves full instructions through tool', async () => {
const wireSkills = readWireSkills(buildWireSkillsMeta(skills))
const prompt = buildSystemPrompt({ cwd: '/work', skills: wireSkills })
const result = await createSkillTool(wireSkills).execute('call-1', { name: '/daily-brief' })

expect(prompt).toContain('- daily-brief: Build a concise daily rundown.')
expect(prompt).not.toContain('Gather current weather')
expect(result.content).toEqual([{ type: 'text', text: skills[0].instruction }])
})

test('rejects unavailable skill names', async () => {
await expect(createSkillTool(skills).execute('call-1', { name: 'unknown' })).rejects.toThrow(
'Skill "unknown" was not found or is disabled.',
)
})
})
34 changes: 34 additions & 0 deletions cli/src/agent/skill-tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { Type } from '@earendil-works/pi-ai'
import type { AgentTool } from '@earendil-works/pi-agent-core'
import { resolveSkill, type SkillDefinition } from '../../../shared/agent-core/skills.ts'

const skillSchema = Type.Object({
name: Type.String({ description: 'Skill name or slash-prefixed slug from the system prompt skill list' }),
})

/**
* Create Pi tool that resolves full instructions from wire-delivered skills.
*
* @param skills - skill definitions received during ACP session setup
* @returns read-only skill lookup tool
*/
export const createSkillTool = (skills: readonly SkillDefinition[]): AgentTool<typeof skillSchema, string> => ({
name: 'skill',
label: 'skill',
description: 'Load full instructions for an available skill using its exact name.',
parameters: skillSchema,
execute: async (_toolCallId, { name }) => {
const skill = resolveSkill(skills, name)
if (!skill) {
throw new Error(`Skill "${name}" was not found or is disabled.`)
}
return {
content: [{ type: 'text', text: skill.instruction }],
details: skill.instruction,
}
},
})
16 changes: 16 additions & 0 deletions cli/src/agent/system-prompt.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@
*/

import { describe, expect, test } from 'bun:test'
import type { SkillDefinition } from '../../../shared/agent-core/skills.ts'
import { buildSystemPrompt } from './system-prompt.ts'

describe('buildSystemPrompt', () => {
Expand Down Expand Up @@ -48,4 +49,19 @@ describe('buildSystemPrompt', () => {
expect(ladder.slice(curl)).toMatch(/permission/i)
expect(prompt).not.toMatch(/no web access/i)
})

test('lists available skills without full instruction bodies', () => {
const skills: SkillDefinition[] = [
{
name: 'daily-brief',
description: 'Build a concise daily rundown.',
instruction: 'Gather current weather and calendar details.',
},
]
const prompt = buildSystemPrompt({ cwd: '/work', skills })

expect(prompt).toContain('- skill')
expect(prompt).toContain('- daily-brief: Build a concise daily rundown.')
expect(prompt).not.toContain('Gather current weather')
})
})
28 changes: 21 additions & 7 deletions cli/src/agent/system-prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,20 +2,24 @@
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at http://mozilla.org/MPL/2.0/. */

import { buildSkillListing, type SkillDefinition } from '../../../shared/agent-core/skills.ts'

type BuildSystemPromptParams = {
cwd: string
modelId?: string
bashEnabled?: boolean
skills?: readonly SkillDefinition[]
}

/** Describe only tools registered on the harness. */
const toolInstructions = (bashEnabled: boolean): string => {
const toolInstructions = (bashEnabled: boolean, skillEnabled: boolean): string => {
const skillInstruction = skillEnabled ? '\n- skill — load full instructions for an available skill' : ''
if (!bashEnabled) {
return `You have four tools:
return `You have ${skillEnabled ? 'five' : 'four'} tools:
- read — read a file
- write — create or overwrite a file
- edit — replace a span within a file
- webfetch — read a specific HTTP or HTTPS URL
- webfetch — read a specific HTTP or HTTPS URL${skillInstruction}

Web access priority:
1. Use web_search when available to search for current information and discover URLs.
Expand All @@ -25,12 +29,12 @@ Bash is unavailable in this workspace-confined session, so do not try curl.
Use read before edit. Make the smallest change that fully solves the task.`
}

return `You have five tools:
return `You have ${skillEnabled ? 'six' : 'five'} tools:
- bash — run shell commands (grep, sed, find, git, language toolchains, tests, …)
- read — read a file
- write — create or overwrite a file
- edit — replace a span within a file
- webfetch — read a specific HTTP or HTTPS URL
- webfetch — read a specific HTTP or HTTPS URL${skillInstruction}

Web access priority:
1. Use web_search when available to search for current information and discover URLs.
Expand All @@ -50,16 +54,25 @@ read before edit. Make the smallest change that fully solves the task.`
* @param params.modelId - when set, names the underlying model so an exposed ACP
* agent can self-identify; omitted for the standalone CLI
* @param params.bashEnabled - whether the harness exposes shell execution
* @param params.skills - wire-delivered skills available through skill tool
* @returns the system prompt string
*/
export const buildSystemPrompt = ({ cwd, modelId, bashEnabled = true }: BuildSystemPromptParams): string => `\
export const buildSystemPrompt = ({
cwd,
modelId,
bashEnabled = true,
skills = [],
}: BuildSystemPromptParams): string => {
const skillListing = buildSkillListing(skills)
return `\
You are thunderbolt, a terminal coding agent${modelId ? `, powered by ${modelId}` : ''}. You operate directly in the user's \
working directory and complete software tasks end-to-end.

Working directory: ${cwd}

# Tools
${toolInstructions(bashEnabled)}
${toolInstructions(bashEnabled, skills.length > 0)}
${skillListing ? `\n${skillListing}\n` : ''}

# How to work
- When you have enough information to act, act. Don't re-derive facts already \
Expand All @@ -80,3 +93,4 @@ ${toolInstructions(bashEnabled)}
When the task is complete, end with one or two sentences on what changed and any \
follow-up the user should know about. Lead with the outcome. Don't recap every \
file you touched — the user watched it happen.`
}
Loading
Loading