@andypai/orb 0.1.1
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/LICENSE +21 -0
- package/README.md +349 -0
- package/assets/orb-logo.svg +75 -0
- package/assets/orb-terminal-session.svg +72 -0
- package/assets/orb-wordmark.svg +77 -0
- package/package.json +76 -0
- package/prompts/anthropic.md +2 -0
- package/prompts/base.md +1 -0
- package/prompts/openai.md +7 -0
- package/prompts/voice.md +12 -0
- package/src/cli.ts +9 -0
- package/src/config.ts +270 -0
- package/src/index.ts +82 -0
- package/src/pipeline/adapters/anthropic.ts +111 -0
- package/src/pipeline/adapters/openai.ts +202 -0
- package/src/pipeline/adapters/types.ts +16 -0
- package/src/pipeline/adapters/utils.ts +131 -0
- package/src/pipeline/frames.ts +113 -0
- package/src/pipeline/observer.ts +36 -0
- package/src/pipeline/observers/metrics.ts +95 -0
- package/src/pipeline/pipeline.ts +43 -0
- package/src/pipeline/processor.ts +57 -0
- package/src/pipeline/processors/agent.ts +38 -0
- package/src/pipeline/processors/tts.ts +120 -0
- package/src/pipeline/task.ts +239 -0
- package/src/pipeline/transports/terminal-text.ts +24 -0
- package/src/pipeline/transports/types.ts +33 -0
- package/src/services/auth-utils.ts +149 -0
- package/src/services/global-config.ts +363 -0
- package/src/services/openai-auth.ts +18 -0
- package/src/services/prompts.ts +76 -0
- package/src/services/provider-defaults.ts +97 -0
- package/src/services/session.ts +204 -0
- package/src/services/streaming-tts.ts +483 -0
- package/src/services/tts.ts +309 -0
- package/src/setup.ts +234 -0
- package/src/types/index.ts +108 -0
- package/src/ui/App.tsx +142 -0
- package/src/ui/components/ActivityTimeline.tsx +60 -0
- package/src/ui/components/AsciiOrb.tsx +92 -0
- package/src/ui/components/ConversationRail.tsx +44 -0
- package/src/ui/components/Footer.tsx +61 -0
- package/src/ui/components/InputPrompt.tsx +88 -0
- package/src/ui/components/MicroOrb.tsx +25 -0
- package/src/ui/components/TTSErrorBanner.tsx +36 -0
- package/src/ui/components/TurnRow.tsx +71 -0
- package/src/ui/components/WelcomeSplash.tsx +78 -0
- package/src/ui/hooks/useAnimationFrame.ts +33 -0
- package/src/ui/hooks/useConversation.ts +195 -0
- package/src/ui/hooks/useKeyboardShortcuts.ts +57 -0
- package/src/ui/hooks/usePipeline.ts +83 -0
- package/src/ui/hooks/useTerminalSize.ts +37 -0
- package/src/ui/utils/markdown.ts +89 -0
- package/src/ui/utils/model-label.ts +20 -0
- package/src/ui/utils/text.ts +18 -0
- package/src/ui/utils/tool-format.ts +40 -0
|
@@ -0,0 +1,202 @@
|
|
|
1
|
+
import { createBashTool } from 'bash-tool'
|
|
2
|
+
import { ToolLoopAgent, stepCountIs, type ToolSet, type StepResult } from 'ai'
|
|
3
|
+
import { buildProviderPrompt } from '../../services/prompts'
|
|
4
|
+
import type { Frame } from '../frames'
|
|
5
|
+
import { createFrame } from '../frames'
|
|
6
|
+
import type { AgentAdapter, AgentAdapterConfig } from './types'
|
|
7
|
+
import { normalizeToolInput, isToolError, formatToolResult } from './utils'
|
|
8
|
+
import { resolveOpenAiProvider } from '../../services/openai-auth'
|
|
9
|
+
|
|
10
|
+
interface OaiToolCall {
|
|
11
|
+
toolCallId: string
|
|
12
|
+
toolName: string
|
|
13
|
+
input: unknown
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface OaiToolResult {
|
|
17
|
+
toolCallId: string
|
|
18
|
+
toolName: string
|
|
19
|
+
output: unknown
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export function createOpenAiAdapter(config: AgentAdapterConfig): AgentAdapter {
|
|
23
|
+
return {
|
|
24
|
+
async *stream(prompt: string): AsyncIterable<Frame> {
|
|
25
|
+
const { appConfig, session, abortController } = config
|
|
26
|
+
const previousResponseId =
|
|
27
|
+
session?.provider === 'openai' ? session.previousResponseId : undefined
|
|
28
|
+
const instructions = await buildProviderPrompt({
|
|
29
|
+
provider: 'openai',
|
|
30
|
+
projectPath: appConfig.projectPath,
|
|
31
|
+
ttsEnabled: appConfig.ttsEnabled,
|
|
32
|
+
})
|
|
33
|
+
|
|
34
|
+
const { tools, sandbox } = await createBashTool({
|
|
35
|
+
uploadDirectory: {
|
|
36
|
+
source: appConfig.projectPath,
|
|
37
|
+
include: '**/*',
|
|
38
|
+
},
|
|
39
|
+
maxFiles: 5000,
|
|
40
|
+
})
|
|
41
|
+
|
|
42
|
+
const { bash, readFile, writeFile } = tools
|
|
43
|
+
const allowedTools: ToolSet = { bash, readFile, writeFile }
|
|
44
|
+
|
|
45
|
+
let accumulatedText = ''
|
|
46
|
+
let toolIndex = 0
|
|
47
|
+
const toolIdToIndex = new Map<string, number>()
|
|
48
|
+
const pendingFrames: Frame[] = []
|
|
49
|
+
|
|
50
|
+
function getOrCreateIndex(toolId: string): number {
|
|
51
|
+
const existing = toolIdToIndex.get(toolId)
|
|
52
|
+
if (existing !== undefined) return existing
|
|
53
|
+
const index = toolIndex++
|
|
54
|
+
toolIdToIndex.set(toolId, index)
|
|
55
|
+
return index
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function registerToolCall(call: OaiToolCall): void {
|
|
59
|
+
const index = getOrCreateIndex(call.toolCallId)
|
|
60
|
+
pendingFrames.push(
|
|
61
|
+
createFrame('tool-call-start', {
|
|
62
|
+
toolCall: {
|
|
63
|
+
id: call.toolCallId,
|
|
64
|
+
index,
|
|
65
|
+
name: call.toolName,
|
|
66
|
+
input: normalizeToolInput(call.input),
|
|
67
|
+
status: 'running',
|
|
68
|
+
},
|
|
69
|
+
}),
|
|
70
|
+
)
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
function registerToolResult(result: OaiToolResult): void {
|
|
74
|
+
const existingIndex = toolIdToIndex.get(result.toolCallId)
|
|
75
|
+
const index = getOrCreateIndex(result.toolCallId)
|
|
76
|
+
|
|
77
|
+
if (existingIndex === undefined) {
|
|
78
|
+
pendingFrames.push(
|
|
79
|
+
createFrame('tool-call-start', {
|
|
80
|
+
toolCall: {
|
|
81
|
+
id: result.toolCallId,
|
|
82
|
+
index,
|
|
83
|
+
name: result.toolName,
|
|
84
|
+
input: {},
|
|
85
|
+
status: 'running',
|
|
86
|
+
},
|
|
87
|
+
}),
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const text = formatToolResult(result.output)
|
|
92
|
+
pendingFrames.push(
|
|
93
|
+
createFrame('tool-call-result', {
|
|
94
|
+
toolIndex: index,
|
|
95
|
+
result: text,
|
|
96
|
+
status: isToolError(result.output) ? 'error' : 'complete',
|
|
97
|
+
}),
|
|
98
|
+
)
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
const { provider } = await resolveOpenAiProvider(appConfig)
|
|
102
|
+
const model = provider.responses(appConfig.llmModel as never)
|
|
103
|
+
|
|
104
|
+
let finalResponseId: string | undefined
|
|
105
|
+
|
|
106
|
+
const runStream = async function* (continuationResponseId?: string): AsyncIterable<Frame> {
|
|
107
|
+
accumulatedText = ''
|
|
108
|
+
toolIndex = 0
|
|
109
|
+
toolIdToIndex.clear()
|
|
110
|
+
pendingFrames.length = 0
|
|
111
|
+
|
|
112
|
+
const agent = new ToolLoopAgent({
|
|
113
|
+
model,
|
|
114
|
+
...(continuationResponseId ? {} : { instructions }),
|
|
115
|
+
tools: allowedTools,
|
|
116
|
+
stopWhen: stepCountIs(20),
|
|
117
|
+
providerOptions: {
|
|
118
|
+
openai: {
|
|
119
|
+
truncation: 'auto',
|
|
120
|
+
...(continuationResponseId
|
|
121
|
+
? {
|
|
122
|
+
previousResponseId: continuationResponseId,
|
|
123
|
+
instructions,
|
|
124
|
+
}
|
|
125
|
+
: {}),
|
|
126
|
+
},
|
|
127
|
+
},
|
|
128
|
+
})
|
|
129
|
+
|
|
130
|
+
const agentStream = await agent.stream({
|
|
131
|
+
prompt,
|
|
132
|
+
onStepFinish: (stepResult: StepResult<ToolSet>) => {
|
|
133
|
+
for (const call of stepResult.toolCalls) {
|
|
134
|
+
registerToolCall(call)
|
|
135
|
+
}
|
|
136
|
+
for (const result of stepResult.toolResults) {
|
|
137
|
+
registerToolResult(result)
|
|
138
|
+
}
|
|
139
|
+
},
|
|
140
|
+
abortSignal: abortController.signal,
|
|
141
|
+
})
|
|
142
|
+
|
|
143
|
+
for await (const chunk of agentStream.textStream) {
|
|
144
|
+
// Drain any tool frames that arrived via onStepFinish
|
|
145
|
+
while (pendingFrames.length > 0) {
|
|
146
|
+
yield pendingFrames.shift()!
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
accumulatedText += chunk
|
|
150
|
+
yield createFrame('agent-text-delta', {
|
|
151
|
+
delta: chunk,
|
|
152
|
+
accumulatedText,
|
|
153
|
+
})
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
// Drain remaining tool frames after text stream ends
|
|
157
|
+
while (pendingFrames.length > 0) {
|
|
158
|
+
yield pendingFrames.shift()!
|
|
159
|
+
}
|
|
160
|
+
|
|
161
|
+
finalResponseId = (await agentStream.response).id
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
const isInvalidContinuationError = (err: unknown): boolean => {
|
|
165
|
+
if (!previousResponseId) return false
|
|
166
|
+
const message = err instanceof Error ? err.message.toLowerCase() : String(err).toLowerCase()
|
|
167
|
+
const statusCode =
|
|
168
|
+
typeof err === 'object' && err !== null && 'statusCode' in err
|
|
169
|
+
? (err as { statusCode?: number }).statusCode
|
|
170
|
+
: undefined
|
|
171
|
+
|
|
172
|
+
return (
|
|
173
|
+
statusCode === 400 ||
|
|
174
|
+
statusCode === 404 ||
|
|
175
|
+
message.includes('previous_response_id') ||
|
|
176
|
+
message.includes('previous response') ||
|
|
177
|
+
message.includes('conversation')
|
|
178
|
+
)
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
try {
|
|
182
|
+
try {
|
|
183
|
+
yield* runStream(previousResponseId)
|
|
184
|
+
} catch (err) {
|
|
185
|
+
if (!isInvalidContinuationError(err)) throw err
|
|
186
|
+
yield* runStream()
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
if (finalResponseId) {
|
|
190
|
+
yield createFrame('agent-text-complete', {
|
|
191
|
+
text: accumulatedText,
|
|
192
|
+
session: { provider: 'openai', previousResponseId: finalResponseId },
|
|
193
|
+
})
|
|
194
|
+
}
|
|
195
|
+
} finally {
|
|
196
|
+
if ('stop' in sandbox && typeof sandbox.stop === 'function') {
|
|
197
|
+
await (sandbox.stop as () => Promise<void>)().catch(() => {})
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
},
|
|
201
|
+
}
|
|
202
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import type { Frame } from '../frames'
|
|
2
|
+
import type { AgentSession, AppConfig } from '../../types'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Normalized interface for agent providers.
|
|
6
|
+
* Each adapter wraps a provider SDK and yields frames instead of calling callbacks.
|
|
7
|
+
*/
|
|
8
|
+
export interface AgentAdapter {
|
|
9
|
+
stream(prompt: string): AsyncIterable<Frame>
|
|
10
|
+
}
|
|
11
|
+
|
|
12
|
+
export interface AgentAdapterConfig {
|
|
13
|
+
appConfig: AppConfig
|
|
14
|
+
session: AgentSession | undefined
|
|
15
|
+
abortController: AbortController
|
|
16
|
+
}
|
|
@@ -0,0 +1,131 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Shared parsing helpers extracted from existing agent implementations.
|
|
3
|
+
* Used by both Anthropic and OpenAI adapters.
|
|
4
|
+
*/
|
|
5
|
+
|
|
6
|
+
// ── Anthropic SDK message parsing ──
|
|
7
|
+
|
|
8
|
+
export type TextBlock = { type: 'text'; text: string }
|
|
9
|
+
export type ToolUseBlock = {
|
|
10
|
+
type: 'tool_use'
|
|
11
|
+
id?: string
|
|
12
|
+
tool_use_id?: string
|
|
13
|
+
name: string
|
|
14
|
+
input?: Record<string, unknown>
|
|
15
|
+
}
|
|
16
|
+
export type ToolResultBlock = {
|
|
17
|
+
type: 'tool_result'
|
|
18
|
+
tool_use_id?: string
|
|
19
|
+
id?: string
|
|
20
|
+
content?: unknown
|
|
21
|
+
is_error?: boolean
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export function getContentBlocks(message: unknown): unknown[] {
|
|
25
|
+
if (typeof message === 'string') {
|
|
26
|
+
return [{ type: 'text', text: message }]
|
|
27
|
+
}
|
|
28
|
+
if (!message || typeof message !== 'object') return []
|
|
29
|
+
const content = (message as { content?: unknown }).content
|
|
30
|
+
return Array.isArray(content) ? content : []
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function hasType(value: unknown, type: string): value is { type: string } {
|
|
34
|
+
return value !== null && typeof value === 'object' && (value as { type?: unknown }).type === type
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isTextBlock(value: unknown): value is TextBlock {
|
|
38
|
+
return hasType(value, 'text')
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function isToolUseBlock(value: unknown): value is ToolUseBlock {
|
|
42
|
+
return hasType(value, 'tool_use') && typeof (value as ToolUseBlock).name === 'string'
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
export function isToolResultBlock(value: unknown): value is ToolResultBlock {
|
|
46
|
+
return hasType(value, 'tool_result')
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export function extractToolResultText(content: unknown): string {
|
|
50
|
+
if (typeof content === 'string') return content
|
|
51
|
+
if (Array.isArray(content)) {
|
|
52
|
+
return content
|
|
53
|
+
.map((block) => {
|
|
54
|
+
if (!block || typeof block !== 'object') return ''
|
|
55
|
+
const typedBlock = block as { type?: string; text?: string }
|
|
56
|
+
if (typedBlock.type === 'text' && typeof typedBlock.text === 'string') {
|
|
57
|
+
return typedBlock.text
|
|
58
|
+
}
|
|
59
|
+
return ''
|
|
60
|
+
})
|
|
61
|
+
.join('')
|
|
62
|
+
}
|
|
63
|
+
if (content && typeof content === 'object' && 'text' in (content as Record<string, unknown>)) {
|
|
64
|
+
return String((content as { text?: unknown }).text ?? '')
|
|
65
|
+
}
|
|
66
|
+
return ''
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
// ── OpenAI tool result parsing ──
|
|
70
|
+
|
|
71
|
+
export function normalizeToolInput(value: unknown): Record<string, unknown> {
|
|
72
|
+
if (!value) return {}
|
|
73
|
+
if (typeof value === 'object') return value as Record<string, unknown>
|
|
74
|
+
if (typeof value === 'string') {
|
|
75
|
+
try {
|
|
76
|
+
const parsed = JSON.parse(value) as unknown
|
|
77
|
+
if (parsed && typeof parsed === 'object') {
|
|
78
|
+
return parsed as Record<string, unknown>
|
|
79
|
+
}
|
|
80
|
+
} catch {
|
|
81
|
+
return { value }
|
|
82
|
+
}
|
|
83
|
+
}
|
|
84
|
+
return { value }
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
export function isToolError(value: unknown): boolean {
|
|
88
|
+
if (!value || typeof value !== 'object') return false
|
|
89
|
+
const typed = value as Record<string, unknown>
|
|
90
|
+
if (typed.isError === true || typed.is_error === true) return true
|
|
91
|
+
if (typeof typed.exitCode === 'number' && typed.exitCode !== 0) return true
|
|
92
|
+
if (typeof typed.success === 'boolean' && typed.success === false) return true
|
|
93
|
+
if (typed.error instanceof Error) return true
|
|
94
|
+
if (typeof typed.error === 'string' && typed.error.length > 0) return true
|
|
95
|
+
return false
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
export function formatToolResult(value: unknown): string {
|
|
99
|
+
if (typeof value === 'string') return value
|
|
100
|
+
if (!value || typeof value !== 'object') return String(value ?? '')
|
|
101
|
+
|
|
102
|
+
const typed = value as Record<string, unknown>
|
|
103
|
+
|
|
104
|
+
const stdout = typeof typed.stdout === 'string' ? typed.stdout : ''
|
|
105
|
+
const stderr = typeof typed.stderr === 'string' ? typed.stderr : ''
|
|
106
|
+
if (stdout || stderr) return [stdout, stderr].filter(Boolean).join('\n').trim()
|
|
107
|
+
|
|
108
|
+
if (typeof typed.content === 'string') return typed.content
|
|
109
|
+
if (typeof typed.result === 'string') return typed.result
|
|
110
|
+
if (typeof typed.success === 'boolean') return typed.success ? 'success' : 'error'
|
|
111
|
+
|
|
112
|
+
try {
|
|
113
|
+
return JSON.stringify(value)
|
|
114
|
+
} catch {
|
|
115
|
+
return String(value)
|
|
116
|
+
}
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
// ── Shared ──
|
|
120
|
+
|
|
121
|
+
export function isAbortError(err: unknown): boolean {
|
|
122
|
+
if (
|
|
123
|
+
typeof globalThis.DOMException !== 'undefined' &&
|
|
124
|
+
err instanceof globalThis.DOMException &&
|
|
125
|
+
err.name === 'AbortError'
|
|
126
|
+
)
|
|
127
|
+
return true
|
|
128
|
+
if (err instanceof Error && err.name === 'AbortError') return true
|
|
129
|
+
if (err instanceof Error && err.message.includes('aborted')) return true
|
|
130
|
+
return false
|
|
131
|
+
}
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
import type { AgentSession, ToolCall, TTSErrorType } from '../types'
|
|
2
|
+
|
|
3
|
+
// ── Base ──
|
|
4
|
+
|
|
5
|
+
interface BaseFrame {
|
|
6
|
+
id: number
|
|
7
|
+
timestamp: number
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
// ── User Input ──
|
|
11
|
+
|
|
12
|
+
export interface UserTextFrame extends BaseFrame {
|
|
13
|
+
kind: 'user-text'
|
|
14
|
+
text: string
|
|
15
|
+
entryId: string
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
// ── Agent Output ──
|
|
19
|
+
|
|
20
|
+
export interface AgentTextDeltaFrame extends BaseFrame {
|
|
21
|
+
kind: 'agent-text-delta'
|
|
22
|
+
delta: string
|
|
23
|
+
accumulatedText: string
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export interface AgentTextCompleteFrame extends BaseFrame {
|
|
27
|
+
kind: 'agent-text-complete'
|
|
28
|
+
text: string
|
|
29
|
+
session?: AgentSession
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export interface ToolCallStartFrame extends BaseFrame {
|
|
33
|
+
kind: 'tool-call-start'
|
|
34
|
+
toolCall: ToolCall
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export interface ToolCallResultFrame extends BaseFrame {
|
|
38
|
+
kind: 'tool-call-result'
|
|
39
|
+
toolIndex: number
|
|
40
|
+
result: string
|
|
41
|
+
status: 'complete' | 'error'
|
|
42
|
+
}
|
|
43
|
+
|
|
44
|
+
export interface AgentSessionFrame extends BaseFrame {
|
|
45
|
+
kind: 'agent-session'
|
|
46
|
+
session: AgentSession
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
export interface AgentErrorFrame extends BaseFrame {
|
|
50
|
+
kind: 'agent-error'
|
|
51
|
+
error: Error
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
// ── TTS ──
|
|
55
|
+
|
|
56
|
+
export interface TTSSpeakingStartFrame extends BaseFrame {
|
|
57
|
+
kind: 'tts-speaking-start'
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
export interface TTSSpeakingEndFrame extends BaseFrame {
|
|
61
|
+
kind: 'tts-speaking-end'
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
export interface TTSErrorFrame extends BaseFrame {
|
|
65
|
+
kind: 'tts-error'
|
|
66
|
+
errorType: TTSErrorType
|
|
67
|
+
message: string
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
// ── Control ──
|
|
71
|
+
|
|
72
|
+
export interface CancelFrame extends BaseFrame {
|
|
73
|
+
kind: 'cancel'
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
// ── Union ──
|
|
77
|
+
|
|
78
|
+
export type Frame =
|
|
79
|
+
| UserTextFrame
|
|
80
|
+
| AgentTextDeltaFrame
|
|
81
|
+
| AgentTextCompleteFrame
|
|
82
|
+
| ToolCallStartFrame
|
|
83
|
+
| ToolCallResultFrame
|
|
84
|
+
| AgentSessionFrame
|
|
85
|
+
| AgentErrorFrame
|
|
86
|
+
| TTSSpeakingStartFrame
|
|
87
|
+
| TTSSpeakingEndFrame
|
|
88
|
+
| TTSErrorFrame
|
|
89
|
+
| CancelFrame
|
|
90
|
+
|
|
91
|
+
// ── Factory ──
|
|
92
|
+
|
|
93
|
+
let nextId = 0
|
|
94
|
+
|
|
95
|
+
type FrameOfKind<K extends Frame['kind']> = Extract<Frame, { kind: K }>
|
|
96
|
+
type FrameData<K extends Frame['kind']> = Omit<FrameOfKind<K>, 'id' | 'timestamp' | 'kind'>
|
|
97
|
+
|
|
98
|
+
export function createFrame<K extends Frame['kind']>(
|
|
99
|
+
kind: K,
|
|
100
|
+
...[data]: FrameData<K> extends Record<string, never> ? [] : [FrameData<K>]
|
|
101
|
+
): FrameOfKind<K> {
|
|
102
|
+
return {
|
|
103
|
+
kind,
|
|
104
|
+
id: nextId++,
|
|
105
|
+
timestamp: Date.now(),
|
|
106
|
+
...(data ?? {}),
|
|
107
|
+
} as FrameOfKind<K>
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
/** Reset frame ID counter (for testing) */
|
|
111
|
+
export function resetFrameIds(): void {
|
|
112
|
+
nextId = 0
|
|
113
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { Frame } from './frames'
|
|
2
|
+
|
|
3
|
+
export interface PipelineMetrics {
|
|
4
|
+
runId: number
|
|
5
|
+
startTime: number
|
|
6
|
+
endTime?: number
|
|
7
|
+
|
|
8
|
+
/** ms from run start to first AgentTextDeltaFrame */
|
|
9
|
+
agentFirstTokenMs?: number
|
|
10
|
+
/** ms from run start to AgentTextCompleteFrame */
|
|
11
|
+
agentCompleteMs?: number
|
|
12
|
+
/** total characters across all text deltas */
|
|
13
|
+
totalTextChars: number
|
|
14
|
+
toolCallCount: number
|
|
15
|
+
toolErrorCount: number
|
|
16
|
+
|
|
17
|
+
/** ms from run start to first TTSSpeakingStartFrame */
|
|
18
|
+
ttsSpeakingStartMs?: number
|
|
19
|
+
/** ms from run start to last TTSSpeakingEndFrame */
|
|
20
|
+
ttsSpeakingEndMs?: number
|
|
21
|
+
ttsErrorCount: number
|
|
22
|
+
|
|
23
|
+
/** frame counts by kind */
|
|
24
|
+
frameCounts: Record<string, number>
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface PipelineObserver {
|
|
28
|
+
/** Called for every frame flowing through the pipeline */
|
|
29
|
+
onFrame(frame: Frame): void
|
|
30
|
+
|
|
31
|
+
/** Called when a run starts */
|
|
32
|
+
onRunStart?(runId: number): void
|
|
33
|
+
|
|
34
|
+
/** Called when a run completes (with full metrics) */
|
|
35
|
+
onRunEnd?(metrics: PipelineMetrics): void
|
|
36
|
+
}
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
import type { Frame } from '../frames'
|
|
2
|
+
import type { PipelineMetrics, PipelineObserver } from '../observer'
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Tracks timing and counts for each pipeline run.
|
|
6
|
+
* Call getMetrics() after onRunEnd to retrieve the snapshot.
|
|
7
|
+
*/
|
|
8
|
+
export function createMetricsObserver(): PipelineObserver & {
|
|
9
|
+
getMetrics(): PipelineMetrics | null
|
|
10
|
+
} {
|
|
11
|
+
let current: PipelineMetrics | null = null
|
|
12
|
+
let lastCompleted: PipelineMetrics | null = null
|
|
13
|
+
|
|
14
|
+
function ensureMetrics(runId: number): PipelineMetrics {
|
|
15
|
+
if (!current || current.runId !== runId) {
|
|
16
|
+
current = {
|
|
17
|
+
runId,
|
|
18
|
+
startTime: Date.now(),
|
|
19
|
+
totalTextChars: 0,
|
|
20
|
+
toolCallCount: 0,
|
|
21
|
+
toolErrorCount: 0,
|
|
22
|
+
ttsErrorCount: 0,
|
|
23
|
+
frameCounts: {},
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
return current
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
return {
|
|
30
|
+
onFrame(frame: Frame): void {
|
|
31
|
+
if (!current) return
|
|
32
|
+
const m = current
|
|
33
|
+
const elapsed = Date.now() - m.startTime
|
|
34
|
+
|
|
35
|
+
// Count every frame by kind
|
|
36
|
+
m.frameCounts[frame.kind] = (m.frameCounts[frame.kind] ?? 0) + 1
|
|
37
|
+
|
|
38
|
+
switch (frame.kind) {
|
|
39
|
+
case 'agent-text-delta':
|
|
40
|
+
if (m.agentFirstTokenMs === undefined) {
|
|
41
|
+
m.agentFirstTokenMs = elapsed
|
|
42
|
+
}
|
|
43
|
+
m.totalTextChars += frame.delta.length
|
|
44
|
+
break
|
|
45
|
+
|
|
46
|
+
case 'agent-text-complete':
|
|
47
|
+
m.agentCompleteMs = elapsed
|
|
48
|
+
break
|
|
49
|
+
|
|
50
|
+
case 'tool-call-start':
|
|
51
|
+
m.toolCallCount++
|
|
52
|
+
break
|
|
53
|
+
|
|
54
|
+
case 'tool-call-result':
|
|
55
|
+
if (frame.status === 'error') m.toolErrorCount++
|
|
56
|
+
break
|
|
57
|
+
|
|
58
|
+
case 'tts-speaking-start':
|
|
59
|
+
if (m.ttsSpeakingStartMs === undefined) {
|
|
60
|
+
m.ttsSpeakingStartMs = elapsed
|
|
61
|
+
}
|
|
62
|
+
break
|
|
63
|
+
|
|
64
|
+
case 'tts-speaking-end':
|
|
65
|
+
m.ttsSpeakingEndMs = elapsed
|
|
66
|
+
break
|
|
67
|
+
|
|
68
|
+
case 'tts-error':
|
|
69
|
+
m.ttsErrorCount++
|
|
70
|
+
break
|
|
71
|
+
}
|
|
72
|
+
},
|
|
73
|
+
|
|
74
|
+
onRunStart(runId: number): void {
|
|
75
|
+
ensureMetrics(runId)
|
|
76
|
+
},
|
|
77
|
+
|
|
78
|
+
onRunEnd(metrics: PipelineMetrics): void {
|
|
79
|
+
if (current) {
|
|
80
|
+
lastCompleted = {
|
|
81
|
+
...current,
|
|
82
|
+
endTime: metrics.endTime ?? Date.now(),
|
|
83
|
+
}
|
|
84
|
+
current = null
|
|
85
|
+
return
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
lastCompleted = metrics
|
|
89
|
+
},
|
|
90
|
+
|
|
91
|
+
getMetrics(): PipelineMetrics | null {
|
|
92
|
+
return lastCompleted ?? current
|
|
93
|
+
},
|
|
94
|
+
}
|
|
95
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import type { Frame } from './frames'
|
|
2
|
+
import type { Processor } from './processor'
|
|
3
|
+
import type { PipelineObserver } from './observer'
|
|
4
|
+
|
|
5
|
+
export interface PipelineConfig {
|
|
6
|
+
processors: Processor[]
|
|
7
|
+
observers?: PipelineObserver[]
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Creates a pipeline that chains processors left-to-right.
|
|
12
|
+
* The pipeline itself is a Processor: AsyncIterable<Frame> → AsyncIterable<Frame>.
|
|
13
|
+
*/
|
|
14
|
+
export function createPipeline(config: PipelineConfig): Processor {
|
|
15
|
+
return (source: AsyncIterable<Frame>) => {
|
|
16
|
+
let stream: AsyncIterable<Frame> = source
|
|
17
|
+
|
|
18
|
+
for (const processor of config.processors) {
|
|
19
|
+
stream = processor(stream)
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
if (config.observers && config.observers.length > 0) {
|
|
23
|
+
stream = tapObservers(stream, config.observers)
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
return stream
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Wraps a stream to notify observers of each frame without modifying the stream.
|
|
32
|
+
*/
|
|
33
|
+
async function* tapObservers(
|
|
34
|
+
upstream: AsyncIterable<Frame>,
|
|
35
|
+
observers: PipelineObserver[],
|
|
36
|
+
): AsyncGenerator<Frame> {
|
|
37
|
+
for await (const frame of upstream) {
|
|
38
|
+
for (const observer of observers) {
|
|
39
|
+
observer.onFrame(frame)
|
|
40
|
+
}
|
|
41
|
+
yield frame
|
|
42
|
+
}
|
|
43
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
import type { Frame } from './frames'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* A Processor transforms an async stream of frames.
|
|
5
|
+
* Compose processors left-to-right: output of one feeds input of the next.
|
|
6
|
+
* Use `finally` blocks for cleanup on cancellation (generator `.return()`).
|
|
7
|
+
*/
|
|
8
|
+
export type Processor = (upstream: AsyncIterable<Frame>) => AsyncIterable<Frame>
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* Per-frame handler: receives one frame, returns zero or more frames.
|
|
12
|
+
* Return `null` to filter a frame out; return the frame to pass it through.
|
|
13
|
+
*/
|
|
14
|
+
type FrameHandler = (frame: Frame) => AsyncIterable<Frame> | Iterable<Frame> | Frame | null
|
|
15
|
+
|
|
16
|
+
interface ProcessorOptions {
|
|
17
|
+
onInit?: () => void | Promise<void>
|
|
18
|
+
onDestroy?: () => void | Promise<void>
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/**
|
|
22
|
+
* Creates a Processor from a simpler per-frame handler.
|
|
23
|
+
* Handles the common case where each input frame produces 0..N output frames.
|
|
24
|
+
*/
|
|
25
|
+
export function createProcessor(handler: FrameHandler, options?: ProcessorOptions): Processor {
|
|
26
|
+
return async function* (upstream: AsyncIterable<Frame>): AsyncGenerator<Frame> {
|
|
27
|
+
await options?.onInit?.()
|
|
28
|
+
try {
|
|
29
|
+
for await (const frame of upstream) {
|
|
30
|
+
const result = handler(frame)
|
|
31
|
+
if (result === null) continue
|
|
32
|
+
if (isAsyncIterable(result)) {
|
|
33
|
+
yield* result
|
|
34
|
+
} else if (isIterable(result)) {
|
|
35
|
+
yield* result
|
|
36
|
+
} else {
|
|
37
|
+
yield result
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
} finally {
|
|
41
|
+
await options?.onDestroy?.()
|
|
42
|
+
}
|
|
43
|
+
}
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** Helper: yield a single frame as an async iterable */
|
|
47
|
+
export async function* singleFrame(frame: Frame): AsyncGenerator<Frame> {
|
|
48
|
+
yield frame
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
function isAsyncIterable(value: unknown): value is AsyncIterable<Frame> {
|
|
52
|
+
return value !== null && typeof value === 'object' && Symbol.asyncIterator in (value as object)
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function isIterable(value: unknown): value is Iterable<Frame> {
|
|
56
|
+
return value !== null && typeof value === 'object' && Symbol.iterator in (value as object)
|
|
57
|
+
}
|