Skip to content

Commit 7f49375

Browse files
committed
feat: add Plugin Common Tools roadmap documentation
- Introduced HTML and Markdown versions of the Plugin Common Tools roadmap. - Documented phases of development, including goals, status, scope, tasks, and acceptance criteria. - Established a status legend for tracking progress on each phase. - Highlighted the need for discussions on approval policies affecting multiple phases.
1 parent ffb384a commit 7f49375

13 files changed

Lines changed: 361 additions & 438 deletions

File tree

examples/copilotkit/src/components/chat-panel.tsx

Lines changed: 6 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -30,7 +30,6 @@ interface ChatPanelProps {
3030
threadId: string
3131
}
3232

33-
type DemoDecision = 'approved' | 'rejected'
3433
type DemoScope = 'call' | 'conversation' | 'run'
3534

3635
interface DemoToolbarProps {
@@ -137,17 +136,8 @@ const useRunChatMessage = () => {
137136
}, [agent, copilotkit])
138137
}
139138

140-
const HITL_APPROVED_MESSAGE_PREFIX = 'Approved HITL request'
141-
const HITL_REJECTED_MESSAGE_PREFIX = 'Rejected HITL request'
142139
const buttonClass = 'rounded border border-border bg-background px-2 py-1 text-xs hover:bg-muted disabled:cursor-not-allowed disabled:opacity-60'
143140

144-
const createResumeMessage = (decision: DemoDecision, id: string, scope?: DemoScope) => {
145-
if (decision === 'rejected')
146-
return `${HITL_REJECTED_MESSAGE_PREFIX} ${id}`
147-
148-
return `${HITL_APPROVED_MESSAGE_PREFIX} ${id}${scope == null ? '' : ` (${scope})`}`
149-
}
150-
151141
const DemoToolbar = ({ agentInstance, demoReplay, hitlControl }: DemoToolbarProps) => {
152142
const runChatMessage = useRunChatMessage()
153143
const [runningAction, setRunningAction] = useState<HITLDemoAction>()
@@ -193,12 +183,11 @@ const DemoToolbar = ({ agentInstance, demoReplay, hitlControl }: DemoToolbarProp
193183
}
194184

195185
const HitlToolCard = ({ args, hitlControl, name, result, status, toolCallId }: HitlToolCardProps) => {
196-
const runChatMessage = useRunChatMessage()
197186
const review = parseHitlReview(result)
198187
const reviewPending = review == null ? false : hitlControl.pending().some(request => request.id === review.id)
199-
const [busy, setBusy] = useState<DemoDecision>()
188+
const [busy, setBusy] = useState<'approved' | 'rejected'>()
200189

201-
const resume = async (decision: DemoDecision, scope?: DemoScope) => {
190+
const decide = (decision: 'approved' | 'rejected', scope?: DemoScope) => {
202191
if (review == null)
203192
return
204193

@@ -210,12 +199,6 @@ const HitlToolCard = ({ args, hitlControl, name, result, status, toolCallId }: H
210199
return
211200

212201
setBusy(decision)
213-
try {
214-
await runChatMessage(createResumeMessage(decision, review.id, scope))
215-
}
216-
finally {
217-
setBusy(undefined)
218-
}
219202
}
220203

221204
if (review != null && !reviewPending)
@@ -231,10 +214,10 @@ const HitlToolCard = ({ args, hitlControl, name, result, status, toolCallId }: H
231214
<div className="mb-2 text-xs text-amber-900">{review.reason}</div>
232215
<pre className="mb-3 max-h-40 overflow-auto rounded bg-background/80 p-2 text-xs leading-snug">{prettyJson(review.args)}</pre>
233216
<div className="flex flex-wrap gap-2">
234-
<button className={buttonClass} disabled={busy != null} onClick={() => void resume('approved', 'call')} type="button">Approve once</button>
235-
<button className={buttonClass} disabled={busy != null} onClick={() => void resume('approved', 'run')} type="button">Approve turn</button>
236-
<button className={buttonClass} disabled={busy != null} onClick={() => void resume('approved', 'conversation')} type="button">Approve conversation</button>
237-
<button className={buttonClass} disabled={busy != null} onClick={() => void resume('rejected')} type="button">Reject</button>
217+
<button className={buttonClass} disabled={busy != null} onClick={() => decide('approved', 'call')} type="button">Approve once</button>
218+
<button className={buttonClass} disabled={busy != null} onClick={() => decide('approved', 'run')} type="button">Approve turn</button>
219+
<button className={buttonClass} disabled={busy != null} onClick={() => decide('approved', 'conversation')} type="button">Approve conversation</button>
220+
<button className={buttonClass} disabled={busy != null} onClick={() => decide('rejected')} type="button">Reject</button>
238221
</div>
239222
</div>
240223
)

examples/copilotkit/test/hitl-runtime.test.ts

Lines changed: 58 additions & 25 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,7 @@ import { createAgent } from '@apeira/core'
44
import { hitl } from '@apeira/plugin-hitl'
55
import { describe, expect, it } from 'vitest'
66

7-
import { createHitlDemoTools, createHitlReplayFetch, createHitlResumeInput } from '../../shared/hitl-demo'
7+
import { createHitlDemoTools, createHitlReplayFetch } from '../../shared/hitl-demo'
88

99
const userInput = (content: string): ItemParam => ({
1010
content,
@@ -27,6 +27,29 @@ const readEvents = async (stream: ReadableStream<AgentEvent>) => {
2727
return events
2828
}
2929

30+
const readEventsAndDecide = async (
31+
stream: ReadableStream<AgentEvent>,
32+
decide: (id: string) => void,
33+
) => {
34+
const reader = stream.getReader()
35+
const events: AgentEvent[] = []
36+
let decided = false
37+
38+
while (true) {
39+
const { done, value } = await reader.read()
40+
if (done)
41+
break
42+
43+
events.push(value)
44+
if (!decided && value.type === 'tool-interruption') {
45+
decided = true
46+
decide(value.interruption.id)
47+
}
48+
}
49+
50+
return events
51+
}
52+
3053
const createDemoSession = () => {
3154
const controller = hitl({ mode: 'ask' })
3255
const replay = createHitlReplayFetch()
@@ -58,68 +81,78 @@ const text = (events: AgentEvent[]) =>
5881
describe('hitl demo runtime integration', () => {
5982
it('asks again after a call-scope approval', async () => {
6083
const { controller, session } = createDemoSession()
61-
const first = await readEvents(session.run(userInput('hitl-demo once')))
84+
const first = await readEventsAndDecide(
85+
session.run(userInput('hitl-demo once')),
86+
id => expect(controller.approve(id, 'call')).toBe(true),
87+
)
6288
const id = interruptions(first)[0].interruption.id
6389

6490
expect(id).toBeDefined()
65-
expect(controller.approve(id, 'call')).toBe(true)
66-
await readEvents(session.run(userInput(createHitlResumeInput(id, 'approved'))))
6791

68-
const second = await readEvents(session.run(userInput('hitl-demo once')))
92+
const second = await readEventsAndDecide(
93+
session.run(userInput('hitl-demo once')),
94+
id => expect(controller.reject(id)).toBe(true),
95+
)
6996
expect(interruptions(second)).toHaveLength(1)
7097
})
7198

7299
it('lets run-scope approval continue repeated same-key calls in one resumed turn only', async () => {
73100
const { controller, session } = createDemoSession()
74-
const first = await readEvents(session.run(userInput('hitl-demo turn')))
101+
const first = await readEventsAndDecide(
102+
session.run(userInput('hitl-demo turn')),
103+
id => expect(controller.approve(id, 'run')).toBe(true),
104+
)
75105
const id = interruptions(first)[0].interruption.id
76106

77107
expect(id).toBeDefined()
78-
expect(controller.approve(id, 'run')).toBe(true)
79108

80-
const resumed = await readEvents(session.run(userInput(createHitlResumeInput(id, 'approved'))))
81-
expect(interruptions(resumed)).toHaveLength(0)
82-
83-
const nextTurn = await readEvents(session.run(userInput('hitl-demo turn')))
109+
const nextTurn = await readEventsAndDecide(
110+
session.run(userInput('hitl-demo turn')),
111+
id => expect(controller.reject(id)).toBe(true),
112+
)
84113
expect(interruptions(nextTurn)).toHaveLength(1)
85114
})
86115

87116
it('remembers conversation-scope approvals by exact key', async () => {
88117
const { controller, session } = createDemoSession()
89-
const first = await readEvents(session.run(userInput('hitl-demo conversation')))
118+
const first = await readEventsAndDecide(
119+
session.run(userInput('hitl-demo conversation')),
120+
id => expect(controller.approve(id, 'conversation')).toBe(true),
121+
)
90122
const id = interruptions(first)[0].interruption.id
91123

92124
expect(id).toBeDefined()
93-
expect(controller.approve(id, 'conversation')).toBe(true)
94-
await readEvents(session.run(userInput(createHitlResumeInput(id, 'approved'))))
95125

96126
const sameKey = await readEvents(session.run(userInput('hitl-demo conversation')))
97127
expect(interruptions(sameKey)).toHaveLength(0)
98128
})
99129

100130
it('keeps approval-key exact after a conversation approval', async () => {
101131
const { controller, session } = createDemoSession()
102-
const first = await readEvents(session.run(userInput('hitl-demo approval-key')))
132+
const first = await readEventsAndDecide(
133+
session.run(userInput('hitl-demo approval-key')),
134+
id => expect(controller.approve(id, 'conversation')).toBe(true),
135+
)
103136
const id = interruptions(first)[0].interruption.id
104137

105138
expect(id).toBeDefined()
106-
expect(controller.approve(id, 'conversation')).toBe(true)
107-
await readEvents(session.run(userInput(createHitlResumeInput(id, 'approved'))))
108139

109-
const dangerous = await readEvents(session.run(userInput('hitl-demo approval-key')))
140+
const dangerous = await readEventsAndDecide(
141+
session.run(userInput('hitl-demo approval-key')),
142+
id => expect(controller.reject(id)).toBe(true),
143+
)
110144
expect(JSON.stringify(dangerous)).toContain('rm -rf .')
111145
expect(interruptions(dangerous)).toHaveLength(1)
112146
})
113147

114148
it('returns a model-visible rejection summary', async () => {
115149
const { controller, session } = createDemoSession()
116-
const first = await readEvents(session.run(userInput('hitl-demo reject')))
117-
const id = interruptions(first)[0].interruption.id
118-
119-
expect(id).toBeDefined()
120-
expect(controller.reject(id, 'TOOL_HITL_REJECTED: denied in demo')).toBe(true)
150+
const events = await readEventsAndDecide(
151+
session.run(userInput('hitl-demo reject')),
152+
id => expect(controller.reject(id, 'TOOL_HITL_REJECTED: denied in demo')).toBe(true),
153+
)
121154

122-
const resumed = await readEvents(session.run(userInput(createHitlResumeInput(id, 'rejected'))))
123-
expect(text(resumed)).toContain('用户拒绝')
155+
expect(interruptions(events)[0].interruption.id).toBeDefined()
156+
expect(text(events)).toContain('用户拒绝')
124157
})
125158
})

examples/pi-tui/src/app.ts

Lines changed: 9 additions & 32 deletions
Original file line numberDiff line numberDiff line change
@@ -11,7 +11,6 @@ import c from 'tinyrainbow'
1111
import { formatSkillInvocation } from '@apeira/plugin-skills'
1212
import { Box, CombinedAutocompleteProvider, Container, Editor, Markdown, matchesKey, ProcessTerminal, Spacer, Text, TUI } from '@earendil-works/pi-tui'
1313

14-
import { createHitlResumeInput } from '../../shared/hitl-demo'
1514
import { agent, hitlControl, hitlDemoEnabled, hitlDemoReplay, skillsDir, skillSet } from './utils/agent'
1615
import { model, workspaceRoot } from './utils/config'
1716
import { appendTranscriptEntry } from './utils/hitl-demo-transcript'
@@ -367,37 +366,25 @@ export const createPiTuiExampleApp = () => {
367366

368367
case 'tool-interruption': {
369368
sawHitlInterruption = true
370-
const hitlEvent = event as AgentEvent & {
371-
interruption: {
372-
id: string
373-
reason?: string
374-
toolCall: {
375-
function: {
376-
arguments?: string
377-
name?: string
378-
}
379-
}
380-
}
381-
}
382-
const toolName = hitlEvent.interruption.toolCall.function.name ?? 'tool'
383-
const args = parseToolArguments(hitlEvent.interruption.toolCall.function.arguments ?? '{}')
369+
const toolName = event.interruption.toolCall.toolName
370+
const args = parseToolArguments(event.interruption.toolCall.args)
384371
const entry = pushEntry('tool', [
385372
formatToolCallSummary(toolName, args),
386-
c.yellow(hitlEvent.interruption.reason ?? 'Human review required.'),
387-
c.gray(`/approve ${hitlEvent.interruption.id} call|turn|conversation`),
388-
c.gray(`/reject ${hitlEvent.interruption.id}`),
373+
c.yellow(event.interruption.reason ?? 'Human review required.'),
374+
c.gray(`/approve ${event.interruption.id} call|turn|conversation`),
375+
c.gray(`/reject ${event.interruption.id}`),
389376
].join('\n'), {
390377
state: 'pending',
391378
title: toolName,
392379
})
393-
toolEntries.set(hitlEvent.interruption.id, entry)
380+
toolEntries.set(event.interruption.id, entry)
394381
break
395382
}
396383

397384
case 'tool-result.done': {
398385
if (hitlDemoEnabled && !sawHitlInterruption && !showedMissingHitlRuntimeWarning) {
399386
showedMissingHitlRuntimeWarning = true
400-
pushSystem('HITL demo warning: the installed xsAI runtime did not emit tool-interruption before executing the tool. Run Apeira with xsAI HITL primitives installed.')
387+
pushSystem('HITL demo warning: no HITL review event was emitted before the tool result. Check plugin-hitl and xsAI tool hook wiring.')
401388
}
402389

403390
const existing = toolEntries.get(event.toolResult.id)
@@ -460,14 +447,6 @@ export const createPiTuiExampleApp = () => {
460447

461448
const unsubscribe = agent.subscribe('apeira', onEvent)
462449

463-
const sendHiddenResume = (id: string, decision: 'approved' | 'rejected') => {
464-
runningTurnId = agent.send({
465-
content: createHitlResumeInput(id, decision),
466-
role: 'user',
467-
type: 'message',
468-
})
469-
}
470-
471450
const sendDemoScenario = (action: HITLDemoAction) => {
472451
if (!hitlDemoEnabled) {
473452
pushSystem('Demo scenarios are only available when APEIRA_HITL_DEMO=1.')
@@ -521,8 +500,7 @@ export const createPiTuiExampleApp = () => {
521500
const normalizedScope = normalizeApprovalScope(scope) ?? { label: 'conversation', value: 'conversation' }
522501
const approved = hitlControl.approve(id, normalizedScope.value)
523502
if (approved) {
524-
pushSystem(`Approved ${id} (${normalizedScope.label}); resuming...`)
525-
sendHiddenResume(id, 'approved')
503+
pushSystem(`Approved ${id} (${normalizedScope.label}); continuing current tool call...`)
526504
}
527505
else {
528506
pushSystem(`No pending HITL request: ${id}`)
@@ -643,8 +621,7 @@ export const createPiTuiExampleApp = () => {
643621

644622
const rejected = hitlControl.reject(id, argument.slice(id.length).trim() || undefined)
645623
if (rejected) {
646-
pushSystem(`Rejected ${id}; resuming...`)
647-
sendHiddenResume(id, 'rejected')
624+
pushSystem(`Rejected ${id}; continuing current tool call...`)
648625
}
649626
else {
650627
pushSystem(`No pending HITL request: ${id}`)

packages/core/src/index.ts

Lines changed: 0 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -31,9 +31,6 @@ export type {
3131
ToolCallControl,
3232
ToolCallControlContext,
3333
ToolCallControlOptions,
34-
ToolCallDecision,
35-
ToolCallPostContext,
36-
ToolCallStatus,
3734
ToolInterruption,
3835
TurnDoneOptions,
3936
TurnStartOptions,

packages/core/src/types/plugin.ts

Lines changed: 9 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -1,9 +1,9 @@
11
import type { ResponsesOptions } from '@xsai-ext/responses'
2-
import type { Tool, ToolCall } from '@xsai/shared-chat'
2+
import type { CompletionToolCall, CompletionToolResult, Message, Tool } from '@xsai/shared-chat'
33

44
import type { Episodic } from '../episodic'
55
import type { AgentContext, ItemParam, MaybePromise } from './base'
6-
import type { AgentEvent } from './event'
6+
import type { AgentEvent, ApeiraEvent } from './event'
77

88
export interface AgentChannelMap {
99
apeira: AgentEvent
@@ -103,39 +103,25 @@ export interface StorageLike {
103103
}
104104

105105
export interface ToolCallControl {
106-
postToolCall?: (context: ToolCallPostContext) => MaybePromise<unknown>
107-
preToolCall?: (context: ToolCallControlContext) => MaybePromise<ToolCallDecision>
106+
postToolCall?: (toolResult: CompletionToolResult, context: ToolCallControlContext) => MaybePromise<CompletionToolResult | void>
107+
preToolCall?: (toolCall: CompletionToolCall, context: ToolCallControlContext) => MaybePromise<CompletionToolCall | CompletionToolResult | void>
108108
}
109109

110110
export interface ToolCallControlContext {
111111
abortSignal?: AbortSignal
112-
messages: unknown[]
113-
parsedArgs: Record<string, unknown>
114-
tool: Tool
115-
toolCall: ToolCall
112+
emit?: (event: ApeiraEvent) => void
113+
messages: Message[]
114+
sessionId?: string
115+
turnId?: string
116116
}
117117

118118
export interface ToolCallControlOptions<T = unknown> extends ResolveToolsOptions<T> {}
119119

120-
export type ToolCallDecision
121-
= | { interruption: ToolInterruption, type: 'interrupt' }
122-
| { message?: string, result?: unknown, type: 'reject' }
123-
| { message?: string, result?: unknown, type: 'skip' }
124-
| { type: 'continue' }
125-
126-
export interface ToolCallPostContext extends ToolCallControlContext {
127-
error?: unknown
128-
result?: unknown
129-
status: ToolCallStatus
130-
}
131-
132-
export type ToolCallStatus = 'error' | 'interrupted' | 'rejected' | 'skipped' | 'success'
133-
134120
export interface ToolInterruption {
135121
data?: unknown
136122
id: string
137123
reason?: string
138-
toolCall: ToolCall
124+
toolCall: CompletionToolCall
139125
}
140126

141127
export interface TurnDoneOptions<T = unknown> extends ResponseOptions<T> {

packages/core/src/utils/agent-runtime.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -34,8 +34,8 @@ export interface AgentRuntimeOptions<T> {
3434
instructions: Instructions<T>
3535
loadSession: () => Promise<SessionState<T> | void> | SessionState<T> | void
3636
onTurnDone: (plugin: AgentPlugin<T>, options: TurnDoneOptions<T>) => Promise<void> | void
37-
pluginStates?: Record<string, unknown>
3837
plugins: AgentPlugin<T>[]
38+
pluginStates?: Record<string, unknown>
3939
ready: () => Promise<void>
4040
responseOptions: Omit<ResponsesOptions, 'abortSignal' | 'input' | 'instructions'>
4141
saveSession: (state: SessionState<T>) => Promise<void> | void
@@ -271,8 +271,8 @@ export const createAgentRuntime = <T>(options: AgentRuntimeOptions<T>): AgentRun
271271
episodic: workingEpisodic,
272272
getContext: options.getContext,
273273
instructions: options.instructions,
274-
pluginState: workingPluginState,
275274
plugins: options.plugins,
275+
pluginState: workingPluginState,
276276
ready: options.ready,
277277
responseOptions: options.responseOptions,
278278
sessionId: options.sessionId,

0 commit comments

Comments
 (0)