@almyty/chat 1.2.0 → 1.4.0
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/README.md +163 -24
- package/dist/app.d.ts +3 -1
- package/dist/app.js +283 -177
- package/dist/args.d.ts +64 -0
- package/dist/args.js +207 -0
- package/dist/commands.d.ts +21 -1
- package/dist/commands.js +50 -4
- package/dist/components.d.ts +7 -1
- package/dist/components.js +18 -26
- package/dist/errors.d.ts +49 -0
- package/dist/errors.js +144 -0
- package/dist/exit-codes.d.ts +32 -0
- package/dist/exit-codes.js +65 -0
- package/dist/headless.d.ts +44 -0
- package/dist/headless.js +106 -0
- package/dist/history.d.ts +51 -0
- package/dist/history.js +146 -0
- package/dist/index.d.ts +2 -1
- package/dist/index.js +112 -40
- package/dist/stream.d.ts +95 -0
- package/dist/stream.js +276 -0
- package/dist/turn.d.ts +53 -0
- package/dist/turn.js +147 -0
- package/dist/version.d.ts +2 -0
- package/dist/version.js +27 -0
- package/dist/viewport.d.ts +61 -0
- package/dist/viewport.js +118 -0
- package/package.json +9 -5
package/dist/stream.d.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run stream, reduced.
|
|
3
|
+
*
|
|
4
|
+
* Every event a run or a pipeline emits lands here and turns into three
|
|
5
|
+
* things: the assistant text so far (rendered as it arrives), lines for
|
|
6
|
+
* the transcript, and a running cost/token tally. Kept pure and free of
|
|
7
|
+
* ink so both the REPL and the non-interactive path drive the same
|
|
8
|
+
* reducer, and so it can be tested without a terminal.
|
|
9
|
+
*
|
|
10
|
+
* Event shapes come from the backend: run events are
|
|
11
|
+
* llm.started / llm.chunk / llm.response / tool.started / tool.result /
|
|
12
|
+
* step.completed / verify.failed / run.completed / run.failed /
|
|
13
|
+
* run.cancelled; workflow pipelines emit execution.started /
|
|
14
|
+
* node.started / node.output / node.completed / node.skipped /
|
|
15
|
+
* execution.completed / execution.failed.
|
|
16
|
+
*/
|
|
17
|
+
import type { StreamEvent } from '@almyty/client';
|
|
18
|
+
export type ActivityRole = 'agent' | 'tool' | 'info' | 'error';
|
|
19
|
+
export interface Activity {
|
|
20
|
+
role: ActivityRole;
|
|
21
|
+
text: string;
|
|
22
|
+
}
|
|
23
|
+
/** What a run cost, as far as the stream has said. */
|
|
24
|
+
export interface Usage {
|
|
25
|
+
/** US dollars. The backend's cost fields are dollars, not cents. */
|
|
26
|
+
cost: number;
|
|
27
|
+
tokens: number;
|
|
28
|
+
steps: number;
|
|
29
|
+
/** Which model answered, when the server names one. */
|
|
30
|
+
model?: string;
|
|
31
|
+
/** Why the router picked it, when routing is in play. */
|
|
32
|
+
rationale?: string;
|
|
33
|
+
/** 1-based position of the answering candidate in the routing plan. */
|
|
34
|
+
attempt?: number;
|
|
35
|
+
}
|
|
36
|
+
export interface StreamState {
|
|
37
|
+
/** Assistant text streamed so far. Rendered live, not at the end. */
|
|
38
|
+
partial: string;
|
|
39
|
+
/** Spinner label: what the agent is doing right now. */
|
|
40
|
+
label: string;
|
|
41
|
+
/** Transcript lines produced since the last drain. */
|
|
42
|
+
emit: Activity[];
|
|
43
|
+
usage: Usage;
|
|
44
|
+
/** Terminal output, once a completion event carries one. */
|
|
45
|
+
output?: string;
|
|
46
|
+
done: boolean;
|
|
47
|
+
failed?: string;
|
|
48
|
+
cancelled: boolean;
|
|
49
|
+
}
|
|
50
|
+
export declare const INITIAL_LABEL = "Thinking";
|
|
51
|
+
export declare function initialStreamState(): StreamState;
|
|
52
|
+
/** Anything the server hands back as an "output", as text. */
|
|
53
|
+
export declare function formatOutput(value: unknown): string;
|
|
54
|
+
/**
|
|
55
|
+
* Fold one event into the state.
|
|
56
|
+
*
|
|
57
|
+
* Returns a new object every time so a React setState sees a change.
|
|
58
|
+
*/
|
|
59
|
+
export declare function reduceStreamEvent(prev: StreamState, event: StreamEvent): StreamState;
|
|
60
|
+
/**
|
|
61
|
+
* The assistant text a finished stream should show.
|
|
62
|
+
*
|
|
63
|
+
* The streamed tokens are preferred over the completion event's output:
|
|
64
|
+
* they are what the user already watched arrive, and re-rendering a
|
|
65
|
+
* re-serialised copy of the same answer makes it flicker.
|
|
66
|
+
*/
|
|
67
|
+
export declare function finalText(state: StreamState, fallbackOutput?: unknown): string;
|
|
68
|
+
/** Take the pending transcript lines, leaving the state without them. */
|
|
69
|
+
export declare function drain(state: StreamState): {
|
|
70
|
+
activities: Activity[];
|
|
71
|
+
state: StreamState;
|
|
72
|
+
};
|
|
73
|
+
/** Add two tallies, for a session total across turns. */
|
|
74
|
+
export declare function addUsage(a: Usage, b: Usage): Usage;
|
|
75
|
+
/** Dollars, at a precision that does not round a real cost to zero. */
|
|
76
|
+
export declare function formatCost(dollars: number): string;
|
|
77
|
+
export declare function formatTokens(tokens: number): string;
|
|
78
|
+
/**
|
|
79
|
+
* One line of attribution: what answered, what it cost.
|
|
80
|
+
*
|
|
81
|
+
* This is the product's whole argument — many models, routed, with the
|
|
82
|
+
* bill attached — so a chat session says it out loud rather than
|
|
83
|
+
* leaving it in an audit log.
|
|
84
|
+
*/
|
|
85
|
+
export declare function formatUsage(usage: Usage): string;
|
|
86
|
+
/**
|
|
87
|
+
* Model attribution off a finished run's steps.
|
|
88
|
+
*
|
|
89
|
+
* The llm.response event does not carry the answering model, so a run
|
|
90
|
+
* that streamed to completion has to be asked afterwards. Only the
|
|
91
|
+
* tool-calling step records `routing` today, so a single-step answer
|
|
92
|
+
* has no attribution to find and this returns null rather than
|
|
93
|
+
* guessing.
|
|
94
|
+
*/
|
|
95
|
+
export declare function routingFromSteps(steps: unknown): Pick<Usage, 'model' | 'rationale' | 'attempt'> | null;
|
package/dist/stream.js
ADDED
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The run stream, reduced.
|
|
3
|
+
*
|
|
4
|
+
* Every event a run or a pipeline emits lands here and turns into three
|
|
5
|
+
* things: the assistant text so far (rendered as it arrives), lines for
|
|
6
|
+
* the transcript, and a running cost/token tally. Kept pure and free of
|
|
7
|
+
* ink so both the REPL and the non-interactive path drive the same
|
|
8
|
+
* reducer, and so it can be tested without a terminal.
|
|
9
|
+
*
|
|
10
|
+
* Event shapes come from the backend: run events are
|
|
11
|
+
* llm.started / llm.chunk / llm.response / tool.started / tool.result /
|
|
12
|
+
* step.completed / verify.failed / run.completed / run.failed /
|
|
13
|
+
* run.cancelled; workflow pipelines emit execution.started /
|
|
14
|
+
* node.started / node.output / node.completed / node.skipped /
|
|
15
|
+
* execution.completed / execution.failed.
|
|
16
|
+
*/
|
|
17
|
+
export const INITIAL_LABEL = 'Thinking';
|
|
18
|
+
export function initialStreamState() {
|
|
19
|
+
return {
|
|
20
|
+
partial: '',
|
|
21
|
+
label: INITIAL_LABEL,
|
|
22
|
+
emit: [],
|
|
23
|
+
usage: { cost: 0, tokens: 0, steps: 0 },
|
|
24
|
+
done: false,
|
|
25
|
+
cancelled: false,
|
|
26
|
+
};
|
|
27
|
+
}
|
|
28
|
+
/** Anything the server hands back as an "output", as text. */
|
|
29
|
+
export function formatOutput(value) {
|
|
30
|
+
if (value == null)
|
|
31
|
+
return '';
|
|
32
|
+
if (typeof value === 'string')
|
|
33
|
+
return value;
|
|
34
|
+
return JSON.stringify(value, null, 2);
|
|
35
|
+
}
|
|
36
|
+
function num(value) {
|
|
37
|
+
return typeof value === 'number' && Number.isFinite(value) ? value : 0;
|
|
38
|
+
}
|
|
39
|
+
function str(value) {
|
|
40
|
+
return typeof value === 'string' && value ? value : undefined;
|
|
41
|
+
}
|
|
42
|
+
/** A tool's duration, when the event reported one. */
|
|
43
|
+
function duration(ms) {
|
|
44
|
+
const n = num(ms);
|
|
45
|
+
if (!n)
|
|
46
|
+
return '';
|
|
47
|
+
return n >= 1000 ? ` ${(n / 1000).toFixed(1)}s` : ` ${Math.round(n)}ms`;
|
|
48
|
+
}
|
|
49
|
+
/**
|
|
50
|
+
* Fold one event into the state.
|
|
51
|
+
*
|
|
52
|
+
* Returns a new object every time so a React setState sees a change.
|
|
53
|
+
*/
|
|
54
|
+
export function reduceStreamEvent(prev, event) {
|
|
55
|
+
const s = { ...prev, emit: [...prev.emit], usage: { ...prev.usage } };
|
|
56
|
+
const d = (event.data ?? {});
|
|
57
|
+
switch (event.type) {
|
|
58
|
+
case 'llm.started':
|
|
59
|
+
s.label = 'Thinking';
|
|
60
|
+
return s;
|
|
61
|
+
case 'llm.chunk': {
|
|
62
|
+
const chunk = str(d.content);
|
|
63
|
+
if (chunk)
|
|
64
|
+
s.partial += chunk;
|
|
65
|
+
return s;
|
|
66
|
+
}
|
|
67
|
+
case 'llm.response': {
|
|
68
|
+
s.usage.cost += num(d.cost);
|
|
69
|
+
const usage = (d.usage ?? {});
|
|
70
|
+
s.usage.tokens += num(usage.totalTokens) || num(usage.inputTokens) + num(usage.outputTokens);
|
|
71
|
+
const routing = (d.routing ?? {});
|
|
72
|
+
s.usage.model = str(d.model) ?? str(routing.vendorModelId) ?? str(routing.modelId) ?? s.usage.model;
|
|
73
|
+
s.usage.rationale = str(routing.rationale) ?? s.usage.rationale;
|
|
74
|
+
if (typeof routing.attempt === 'number')
|
|
75
|
+
s.usage.attempt = routing.attempt;
|
|
76
|
+
const content = str(d.content);
|
|
77
|
+
const toolCalls = Array.isArray(d.toolCalls) ? d.toolCalls : [];
|
|
78
|
+
// A provider without token streaming emits no chunks at all, so
|
|
79
|
+
// the response body is the only copy of the answer.
|
|
80
|
+
if (content && !s.partial)
|
|
81
|
+
s.partial = content;
|
|
82
|
+
// Text that came before a tool call is a preamble, not the
|
|
83
|
+
// answer: flush it so the tool lines read underneath it, and
|
|
84
|
+
// start the next step with an empty buffer.
|
|
85
|
+
if (toolCalls.length) {
|
|
86
|
+
if (s.partial.trim())
|
|
87
|
+
s.emit.push({ role: 'agent', text: s.partial.trim() });
|
|
88
|
+
s.partial = '';
|
|
89
|
+
s.label = 'Working';
|
|
90
|
+
}
|
|
91
|
+
return s;
|
|
92
|
+
}
|
|
93
|
+
case 'tool.started': {
|
|
94
|
+
const tool = str(d.tool);
|
|
95
|
+
if (tool) {
|
|
96
|
+
s.emit.push({ role: 'tool', text: tool });
|
|
97
|
+
s.label = `Running ${tool}`;
|
|
98
|
+
}
|
|
99
|
+
return s;
|
|
100
|
+
}
|
|
101
|
+
case 'tool.result': {
|
|
102
|
+
const tool = str(d.tool) ?? 'tool';
|
|
103
|
+
const ok = d.success !== false;
|
|
104
|
+
s.emit.push({
|
|
105
|
+
role: ok ? 'info' : 'error',
|
|
106
|
+
text: `${tool} ${ok ? 'ok' : 'failed'}${duration(d.executionTime)}`,
|
|
107
|
+
});
|
|
108
|
+
s.label = 'Thinking';
|
|
109
|
+
return s;
|
|
110
|
+
}
|
|
111
|
+
case 'step.completed': {
|
|
112
|
+
s.usage.steps += 1;
|
|
113
|
+
const status = str(d.status);
|
|
114
|
+
if (status === 'waiting_input')
|
|
115
|
+
s.label = 'Waiting for your input';
|
|
116
|
+
else if (status === 'sleeping')
|
|
117
|
+
s.label = 'Sleeping';
|
|
118
|
+
else if (status === 'revising')
|
|
119
|
+
s.label = 'Revising';
|
|
120
|
+
else
|
|
121
|
+
s.label = 'Thinking';
|
|
122
|
+
return s;
|
|
123
|
+
}
|
|
124
|
+
case 'verify.failed':
|
|
125
|
+
s.emit.push({ role: 'info', text: 'verification rejected the draft — revising' });
|
|
126
|
+
s.label = 'Revising';
|
|
127
|
+
return s;
|
|
128
|
+
// ── Workflow pipelines ───────────────────────────────────────
|
|
129
|
+
case 'execution.started':
|
|
130
|
+
s.label = 'Starting';
|
|
131
|
+
return s;
|
|
132
|
+
case 'node.started': {
|
|
133
|
+
const label = str(d.nodeType) ?? 'node';
|
|
134
|
+
const id = str(d.nodeId);
|
|
135
|
+
s.emit.push({ role: 'tool', text: id ? `${label} · ${id}` : label });
|
|
136
|
+
s.label = `Running ${label}`;
|
|
137
|
+
return s;
|
|
138
|
+
}
|
|
139
|
+
case 'node.completed': {
|
|
140
|
+
s.usage.cost += num(d.cost);
|
|
141
|
+
s.usage.tokens += num(d.tokens);
|
|
142
|
+
s.usage.steps += 1;
|
|
143
|
+
const error = str(d.error);
|
|
144
|
+
if (error)
|
|
145
|
+
s.emit.push({ role: 'error', text: `${str(d.nodeId) ?? 'node'} failed: ${error}` });
|
|
146
|
+
return s;
|
|
147
|
+
}
|
|
148
|
+
case 'node.skipped':
|
|
149
|
+
s.emit.push({ role: 'info', text: `${str(d.nodeId) ?? 'node'} skipped` });
|
|
150
|
+
return s;
|
|
151
|
+
case 'node.output':
|
|
152
|
+
// Intermediate node output is noise in a chat transcript; the
|
|
153
|
+
// pipeline's final output arrives on execution.completed.
|
|
154
|
+
return s;
|
|
155
|
+
case 'execution.completed': {
|
|
156
|
+
s.usage.cost += num(d.totalCost);
|
|
157
|
+
s.usage.tokens += num(d.totalTokens);
|
|
158
|
+
const output = formatOutput(d.output);
|
|
159
|
+
if (output)
|
|
160
|
+
s.output = output;
|
|
161
|
+
s.done = true;
|
|
162
|
+
return s;
|
|
163
|
+
}
|
|
164
|
+
case 'execution.failed':
|
|
165
|
+
s.failed = str(d.error) ?? str(d.message) ?? 'The pipeline failed';
|
|
166
|
+
s.done = true;
|
|
167
|
+
return s;
|
|
168
|
+
// ── Autonomous runs ──────────────────────────────────────────
|
|
169
|
+
case 'run.completed': {
|
|
170
|
+
const output = formatOutput(d.output);
|
|
171
|
+
if (output)
|
|
172
|
+
s.output = output;
|
|
173
|
+
s.done = true;
|
|
174
|
+
return s;
|
|
175
|
+
}
|
|
176
|
+
case 'run.failed':
|
|
177
|
+
s.failed = str(d.error) ?? 'The run failed';
|
|
178
|
+
s.done = true;
|
|
179
|
+
return s;
|
|
180
|
+
case 'run.cancelled':
|
|
181
|
+
s.cancelled = true;
|
|
182
|
+
s.done = true;
|
|
183
|
+
return s;
|
|
184
|
+
default:
|
|
185
|
+
return s;
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
/**
|
|
189
|
+
* The assistant text a finished stream should show.
|
|
190
|
+
*
|
|
191
|
+
* The streamed tokens are preferred over the completion event's output:
|
|
192
|
+
* they are what the user already watched arrive, and re-rendering a
|
|
193
|
+
* re-serialised copy of the same answer makes it flicker.
|
|
194
|
+
*/
|
|
195
|
+
export function finalText(state, fallbackOutput) {
|
|
196
|
+
const streamed = state.partial.trim();
|
|
197
|
+
if (streamed)
|
|
198
|
+
return streamed;
|
|
199
|
+
if (state.output)
|
|
200
|
+
return state.output;
|
|
201
|
+
return formatOutput(fallbackOutput);
|
|
202
|
+
}
|
|
203
|
+
/** Take the pending transcript lines, leaving the state without them. */
|
|
204
|
+
export function drain(state) {
|
|
205
|
+
if (!state.emit.length)
|
|
206
|
+
return { activities: [], state };
|
|
207
|
+
return { activities: state.emit, state: { ...state, emit: [] } };
|
|
208
|
+
}
|
|
209
|
+
/** Add two tallies, for a session total across turns. */
|
|
210
|
+
export function addUsage(a, b) {
|
|
211
|
+
return {
|
|
212
|
+
cost: a.cost + b.cost,
|
|
213
|
+
tokens: a.tokens + b.tokens,
|
|
214
|
+
steps: a.steps + b.steps,
|
|
215
|
+
model: b.model ?? a.model,
|
|
216
|
+
rationale: b.rationale ?? a.rationale,
|
|
217
|
+
attempt: b.attempt ?? a.attempt,
|
|
218
|
+
};
|
|
219
|
+
}
|
|
220
|
+
/** Dollars, at a precision that does not round a real cost to zero. */
|
|
221
|
+
export function formatCost(dollars) {
|
|
222
|
+
if (!dollars)
|
|
223
|
+
return '$0';
|
|
224
|
+
if (dollars < 0.01)
|
|
225
|
+
return `$${dollars.toFixed(4)}`;
|
|
226
|
+
if (dollars < 1)
|
|
227
|
+
return `$${dollars.toFixed(3)}`;
|
|
228
|
+
return `$${dollars.toFixed(2)}`;
|
|
229
|
+
}
|
|
230
|
+
export function formatTokens(tokens) {
|
|
231
|
+
return tokens.toLocaleString('en-US');
|
|
232
|
+
}
|
|
233
|
+
/**
|
|
234
|
+
* One line of attribution: what answered, what it cost.
|
|
235
|
+
*
|
|
236
|
+
* This is the product's whole argument — many models, routed, with the
|
|
237
|
+
* bill attached — so a chat session says it out loud rather than
|
|
238
|
+
* leaving it in an audit log.
|
|
239
|
+
*/
|
|
240
|
+
export function formatUsage(usage) {
|
|
241
|
+
const parts = [];
|
|
242
|
+
if (usage.model) {
|
|
243
|
+
parts.push(usage.attempt && usage.attempt > 1 ? `${usage.model} (attempt ${usage.attempt})` : usage.model);
|
|
244
|
+
}
|
|
245
|
+
if (usage.tokens)
|
|
246
|
+
parts.push(`${formatTokens(usage.tokens)} tok`);
|
|
247
|
+
if (usage.cost)
|
|
248
|
+
parts.push(formatCost(usage.cost));
|
|
249
|
+
if (usage.steps)
|
|
250
|
+
parts.push(`${usage.steps} step${usage.steps === 1 ? '' : 's'}`);
|
|
251
|
+
return parts.join(' · ');
|
|
252
|
+
}
|
|
253
|
+
/**
|
|
254
|
+
* Model attribution off a finished run's steps.
|
|
255
|
+
*
|
|
256
|
+
* The llm.response event does not carry the answering model, so a run
|
|
257
|
+
* that streamed to completion has to be asked afterwards. Only the
|
|
258
|
+
* tool-calling step records `routing` today, so a single-step answer
|
|
259
|
+
* has no attribution to find and this returns null rather than
|
|
260
|
+
* guessing.
|
|
261
|
+
*/
|
|
262
|
+
export function routingFromSteps(steps) {
|
|
263
|
+
if (!Array.isArray(steps))
|
|
264
|
+
return null;
|
|
265
|
+
for (let i = steps.length - 1; i >= 0; i--) {
|
|
266
|
+
const routing = steps[i]?.output?.routing;
|
|
267
|
+
if (routing && typeof routing === 'object') {
|
|
268
|
+
return {
|
|
269
|
+
model: str(routing.vendorModelId) ?? str(routing.modelId),
|
|
270
|
+
rationale: str(routing.rationale),
|
|
271
|
+
attempt: typeof routing.attempt === 'number' ? routing.attempt : undefined,
|
|
272
|
+
};
|
|
273
|
+
}
|
|
274
|
+
}
|
|
275
|
+
return null;
|
|
276
|
+
}
|
package/dist/turn.d.ts
ADDED
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One turn of a conversation, driven the same way in both modes.
|
|
3
|
+
*
|
|
4
|
+
* The REPL and the non-interactive path used to be different code, and
|
|
5
|
+
* the non-interactive path did not exist. Both now call `runTurn`,
|
|
6
|
+
* which owns the choice between a streamed autonomous run and a
|
|
7
|
+
* streamed workflow pipeline, the cancellation handshake, and the
|
|
8
|
+
* cost tally. It takes the gateway as an interface so it can be tested
|
|
9
|
+
* against a fake without a network or a terminal.
|
|
10
|
+
*/
|
|
11
|
+
import type { AgentRun, RunLimits, StreamEvent } from '@almyty/client';
|
|
12
|
+
import { type Activity, type Usage } from './stream.js';
|
|
13
|
+
/** The part of GatewayClient a turn needs. */
|
|
14
|
+
export interface TurnTarget {
|
|
15
|
+
startRun(input: any, options?: RunLimits & {
|
|
16
|
+
conversationId?: string;
|
|
17
|
+
}): Promise<AgentRun>;
|
|
18
|
+
streamRun(runId: string, handler: (event: StreamEvent) => void, signal?: AbortSignal): Promise<AgentRun>;
|
|
19
|
+
streamInvoke(input: Record<string, any>, handler: (event: StreamEvent) => void, signal?: AbortSignal): Promise<void>;
|
|
20
|
+
invoke(input: Record<string, any>): Promise<any>;
|
|
21
|
+
sendRunInput(runId: string, input: string): Promise<void>;
|
|
22
|
+
cancelRun(runId: string): Promise<void>;
|
|
23
|
+
cancelExecution(executionId: string): Promise<void>;
|
|
24
|
+
}
|
|
25
|
+
export interface TurnHooks {
|
|
26
|
+
/** The assistant text so far, on every change. */
|
|
27
|
+
partial?(text: string): void;
|
|
28
|
+
/** A transcript line: a tool call, a node, a warning. */
|
|
29
|
+
activity?(activity: Activity): void;
|
|
30
|
+
/** What the agent is doing, for a spinner. */
|
|
31
|
+
label?(label: string): void;
|
|
32
|
+
}
|
|
33
|
+
export type TurnStatus = 'completed' | 'failed' | 'cancelled' | 'waiting_input';
|
|
34
|
+
export interface TurnResult {
|
|
35
|
+
status: TurnStatus;
|
|
36
|
+
text: string;
|
|
37
|
+
usage: Usage;
|
|
38
|
+
error?: string;
|
|
39
|
+
runId?: string;
|
|
40
|
+
conversationId?: string;
|
|
41
|
+
/** Set when the agent asked a question and is holding the run open. */
|
|
42
|
+
pendingRunId?: string;
|
|
43
|
+
}
|
|
44
|
+
export interface TurnOptions {
|
|
45
|
+
mode?: string;
|
|
46
|
+
conversationId?: string;
|
|
47
|
+
/** A run already waiting on input: this message answers it. */
|
|
48
|
+
pendingRunId?: string;
|
|
49
|
+
limits?: RunLimits;
|
|
50
|
+
signal?: AbortSignal;
|
|
51
|
+
hooks?: TurnHooks;
|
|
52
|
+
}
|
|
53
|
+
export declare function runTurn(target: TurnTarget, message: string, options?: TurnOptions): Promise<TurnResult>;
|
package/dist/turn.js
ADDED
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One turn of a conversation, driven the same way in both modes.
|
|
3
|
+
*
|
|
4
|
+
* The REPL and the non-interactive path used to be different code, and
|
|
5
|
+
* the non-interactive path did not exist. Both now call `runTurn`,
|
|
6
|
+
* which owns the choice between a streamed autonomous run and a
|
|
7
|
+
* streamed workflow pipeline, the cancellation handshake, and the
|
|
8
|
+
* cost tally. It takes the gateway as an interface so it can be tested
|
|
9
|
+
* against a fake without a network or a terminal.
|
|
10
|
+
*/
|
|
11
|
+
import { drain, finalText, initialStreamState, reduceStreamEvent, routingFromSteps, } from './stream.js';
|
|
12
|
+
function isAborted(err, signal) {
|
|
13
|
+
const e = err;
|
|
14
|
+
return !!signal?.aborted || (!!e && (e.name === 'AbortError' || e.code === 'ABORT_ERR'));
|
|
15
|
+
}
|
|
16
|
+
/** True when the pipeline stream endpoint is not there to be used. */
|
|
17
|
+
function streamUnavailable(err) {
|
|
18
|
+
const status = err?.status;
|
|
19
|
+
if (status === 404 || status === 405)
|
|
20
|
+
return true;
|
|
21
|
+
return /Unknown agent action|SSE 404|SSE 405/.test(err?.message ?? '');
|
|
22
|
+
}
|
|
23
|
+
/** Wire the reducer's output into the hooks, once per event. */
|
|
24
|
+
function pump(hooks, state, event) {
|
|
25
|
+
const before = state.partial;
|
|
26
|
+
const beforeLabel = state.label;
|
|
27
|
+
let next = reduceStreamEvent(state, event);
|
|
28
|
+
const drained = drain(next);
|
|
29
|
+
next = drained.state;
|
|
30
|
+
for (const activity of drained.activities)
|
|
31
|
+
hooks?.activity?.(activity);
|
|
32
|
+
if (next.partial !== before)
|
|
33
|
+
hooks?.partial?.(next.partial);
|
|
34
|
+
if (next.label !== beforeLabel)
|
|
35
|
+
hooks?.label?.(next.label);
|
|
36
|
+
return next;
|
|
37
|
+
}
|
|
38
|
+
/** Fill in attribution and totals the stream did not carry. */
|
|
39
|
+
function settleUsage(usage, run) {
|
|
40
|
+
const settled = { ...usage };
|
|
41
|
+
if (!settled.model && run) {
|
|
42
|
+
const routing = routingFromSteps(run.steps);
|
|
43
|
+
if (routing) {
|
|
44
|
+
settled.model = routing.model ?? settled.model;
|
|
45
|
+
settled.rationale = routing.rationale ?? settled.rationale;
|
|
46
|
+
settled.attempt = routing.attempt ?? settled.attempt;
|
|
47
|
+
}
|
|
48
|
+
}
|
|
49
|
+
// The persisted totals are authoritative when the stream missed
|
|
50
|
+
// events (a reconnect, or a fallback to polling).
|
|
51
|
+
if (run && typeof run.totalCost === 'number' && run.totalCost > settled.cost)
|
|
52
|
+
settled.cost = run.totalCost;
|
|
53
|
+
if (run && typeof run.totalTokens === 'number' && run.totalTokens > settled.tokens)
|
|
54
|
+
settled.tokens = run.totalTokens;
|
|
55
|
+
return settled;
|
|
56
|
+
}
|
|
57
|
+
export async function runTurn(target, message, options = {}) {
|
|
58
|
+
const { hooks, signal } = options;
|
|
59
|
+
let state = initialStreamState();
|
|
60
|
+
hooks?.label?.(state.label);
|
|
61
|
+
if (options.mode === 'autonomous') {
|
|
62
|
+
let runId;
|
|
63
|
+
let conversationId = options.conversationId;
|
|
64
|
+
if (options.pendingRunId) {
|
|
65
|
+
await target.sendRunInput(options.pendingRunId, message);
|
|
66
|
+
runId = options.pendingRunId;
|
|
67
|
+
}
|
|
68
|
+
else {
|
|
69
|
+
const run = await target.startRun(message, { ...options.limits, conversationId });
|
|
70
|
+
runId = run.id;
|
|
71
|
+
conversationId = run.conversationId ?? conversationId;
|
|
72
|
+
}
|
|
73
|
+
let final;
|
|
74
|
+
try {
|
|
75
|
+
final = await target.streamRun(runId, (event) => { state = pump(hooks, state, event); }, signal);
|
|
76
|
+
}
|
|
77
|
+
catch (err) {
|
|
78
|
+
if (!isAborted(err, signal))
|
|
79
|
+
throw err;
|
|
80
|
+
// Stop the run where it is running, not just where it is watched.
|
|
81
|
+
// Killing the client alone leaves the run spending money with
|
|
82
|
+
// nobody reading the answer.
|
|
83
|
+
await target.cancelRun(runId).catch(() => { });
|
|
84
|
+
return {
|
|
85
|
+
status: 'cancelled',
|
|
86
|
+
text: state.partial.trim(),
|
|
87
|
+
usage: settleUsage(state.usage, undefined),
|
|
88
|
+
runId,
|
|
89
|
+
conversationId,
|
|
90
|
+
};
|
|
91
|
+
}
|
|
92
|
+
if (state.cancelled) {
|
|
93
|
+
return { status: 'cancelled', text: state.partial.trim(), usage: settleUsage(state.usage, final), runId, conversationId };
|
|
94
|
+
}
|
|
95
|
+
const usage = settleUsage(state.usage, final);
|
|
96
|
+
const text = finalText(state, final?.output);
|
|
97
|
+
if (final?.status === 'waiting_input') {
|
|
98
|
+
return { status: 'waiting_input', text, usage, runId, conversationId, pendingRunId: runId };
|
|
99
|
+
}
|
|
100
|
+
if (state.failed || final?.status === 'failed' || final?.status === 'timeout') {
|
|
101
|
+
return { status: 'failed', text, usage, error: state.failed ?? final?.error ?? 'The run failed', runId, conversationId };
|
|
102
|
+
}
|
|
103
|
+
return { status: 'completed', text, usage, runId, conversationId };
|
|
104
|
+
}
|
|
105
|
+
// Workflow agents: a pipeline, streamed so its nodes are visible.
|
|
106
|
+
// The execution id arrives on execution.started and is the only handle
|
|
107
|
+
// on the pipeline: it is not known before the stream opens, and without
|
|
108
|
+
// it a cancel has nothing to name.
|
|
109
|
+
let executionId;
|
|
110
|
+
try {
|
|
111
|
+
await target.streamInvoke({ message }, (event) => {
|
|
112
|
+
const id = event?.data?.executionId;
|
|
113
|
+
if (typeof id === 'string' && id)
|
|
114
|
+
executionId = id;
|
|
115
|
+
state = pump(hooks, state, event);
|
|
116
|
+
}, signal);
|
|
117
|
+
}
|
|
118
|
+
catch (err) {
|
|
119
|
+
if (isAborted(err, signal)) {
|
|
120
|
+
// Stop the pipeline where it is running, not just where it is
|
|
121
|
+
// watched -- the same handshake the autonomous branch does with
|
|
122
|
+
// cancelRun. A workflow run is an execution, not a run, so
|
|
123
|
+
// cancelRun could never reach it: Ctrl-C reported `cancelled`
|
|
124
|
+
// while the pipeline ran on, billing model calls nobody would read.
|
|
125
|
+
if (executionId)
|
|
126
|
+
await target.cancelExecution(executionId).catch(() => { });
|
|
127
|
+
return { status: 'cancelled', text: state.partial.trim(), usage: state.usage, conversationId: options.conversationId };
|
|
128
|
+
}
|
|
129
|
+
if (!streamUnavailable(err))
|
|
130
|
+
throw err;
|
|
131
|
+
// A deployment without the pipeline stream still answers the
|
|
132
|
+
// blocking call; the run is just invisible while it happens.
|
|
133
|
+
const result = await target.invoke({ message });
|
|
134
|
+
const output = result?.output ?? result?.data?.output ?? result;
|
|
135
|
+
return {
|
|
136
|
+
status: result?.status && result.status !== 'completed' ? 'failed' : 'completed',
|
|
137
|
+
text: finalText(state, output),
|
|
138
|
+
usage: state.usage,
|
|
139
|
+
error: result?.error,
|
|
140
|
+
conversationId: options.conversationId,
|
|
141
|
+
};
|
|
142
|
+
}
|
|
143
|
+
if (state.failed) {
|
|
144
|
+
return { status: 'failed', text: finalText(state), usage: state.usage, error: state.failed, conversationId: options.conversationId };
|
|
145
|
+
}
|
|
146
|
+
return { status: 'completed', text: finalText(state), usage: state.usage, conversationId: options.conversationId };
|
|
147
|
+
}
|
package/dist/version.js
ADDED
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The CLI's own version, read from its package.json at startup.
|
|
3
|
+
*
|
|
4
|
+
* Hardcoding it drifted: `--version` answered 0.2.0 while the
|
|
5
|
+
* published package was 1.2.0, so a bug report never identified the
|
|
6
|
+
* build it came from. Both `dist/index.js` and `src/index.tsx` sit one
|
|
7
|
+
* directory below the package root, so the same relative path resolves
|
|
8
|
+
* for the built bin and for `tsx src/index.tsx`.
|
|
9
|
+
*
|
|
10
|
+
* A single-file executable (the `/apps` tui target compiles this client
|
|
11
|
+
* with `bun --compile`) has no package.json to read, so it falls back.
|
|
12
|
+
* A compiled terminal app's version is the app's, not this client's,
|
|
13
|
+
* and the build is where that belongs.
|
|
14
|
+
*/
|
|
15
|
+
import { readFileSync } from 'node:fs';
|
|
16
|
+
export function readVersion(fallback = '0.0.0') {
|
|
17
|
+
try {
|
|
18
|
+
const pkg = JSON.parse(readFileSync(new URL('../package.json', import.meta.url), 'utf-8'));
|
|
19
|
+
return typeof pkg.version === 'string' && pkg.version.length > 0
|
|
20
|
+
? pkg.version
|
|
21
|
+
: fallback;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return fallback;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
export const VERSION = readVersion();
|
|
@@ -0,0 +1,61 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* How much of the transcript fits on screen.
|
|
3
|
+
*
|
|
4
|
+
* ink redraws its whole tree, so a transcript taller than the terminal
|
|
5
|
+
* garbles the frame instead of scrolling it. The fix is to draw only
|
|
6
|
+
* what fits and say how much is above. Pure arithmetic, kept out of the
|
|
7
|
+
* components so it can be tested at any terminal size — including the
|
|
8
|
+
* 40-column window nobody remembers to try.
|
|
9
|
+
*/
|
|
10
|
+
/** Narrowest width worth laying out for. */
|
|
11
|
+
export declare const MIN_COLUMNS = 40;
|
|
12
|
+
/** Fewest transcript rows to draw, even on a very short terminal. */
|
|
13
|
+
export declare const MIN_ROWS = 5;
|
|
14
|
+
export declare function columns(stdoutColumns?: number): number;
|
|
15
|
+
/**
|
|
16
|
+
* Rows left for the transcript once the header, prompt and status bar
|
|
17
|
+
* have taken theirs.
|
|
18
|
+
*/
|
|
19
|
+
export declare function usableRows(stdoutRows: number | undefined, chromeRows: number): number;
|
|
20
|
+
/**
|
|
21
|
+
* How many terminal columns a string occupies.
|
|
22
|
+
*
|
|
23
|
+
* `String.length` counts UTF-16 units, which is not width. A CJK
|
|
24
|
+
* character is one unit and two columns; an emoji is two units and two
|
|
25
|
+
* columns; a combining mark or a zero-width joiner is a unit and no
|
|
26
|
+
* columns. Measuring by length made a Japanese or Chinese transcript
|
|
27
|
+
* estimate half its real height, so the window packed twice what fits
|
|
28
|
+
* and ink drew a frame taller than the terminal — the garbling the rest
|
|
29
|
+
* of this module exists to prevent.
|
|
30
|
+
*/
|
|
31
|
+
export declare function displayWidth(text: string): number;
|
|
32
|
+
/**
|
|
33
|
+
* Rows one message will occupy at this width.
|
|
34
|
+
*
|
|
35
|
+
* Counts the wrap, not the characters: a 300-character paragraph is
|
|
36
|
+
* four rows at 80 columns and eight at 40.
|
|
37
|
+
*/
|
|
38
|
+
export declare function estimateLines(text: string, cols: number): number;
|
|
39
|
+
export interface WindowSelection {
|
|
40
|
+
startIdx: number;
|
|
41
|
+
endIdx: number;
|
|
42
|
+
hiddenBefore: number;
|
|
43
|
+
hiddenAfter: number;
|
|
44
|
+
}
|
|
45
|
+
/**
|
|
46
|
+
* The slice of messages to draw.
|
|
47
|
+
*
|
|
48
|
+
* Walks backwards from the end so the newest message is always visible:
|
|
49
|
+
* a transcript that scrolls away from the answer you just asked for is
|
|
50
|
+
* worse than one that hides the beginning. The newest message is kept
|
|
51
|
+
* even when it alone is taller than the window, because dropping it
|
|
52
|
+
* would leave an empty screen.
|
|
53
|
+
*/
|
|
54
|
+
export declare function selectWindow(messages: Array<{
|
|
55
|
+
text: string;
|
|
56
|
+
}>, options: {
|
|
57
|
+
rows: number;
|
|
58
|
+
cols: number;
|
|
59
|
+
reservedRows?: number;
|
|
60
|
+
scrollOffset?: number;
|
|
61
|
+
}): WindowSelection;
|