klyro 0.1.61 → 0.1.63
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/READ.md +68 -0
- package/dist/agent/anthropic-adapter.d.ts +30 -9
- package/dist/agent/anthropic-adapter.js +101 -48
- package/dist/agent/provider-adapter.js +61 -38
- package/dist/agent/runtime.d.ts +3 -1
- package/dist/agent/runtime.js +227 -25
- package/dist/cli/config.d.ts +21 -0
- package/dist/cli/config.js +31 -0
- package/dist/cli/repl.js +34 -13
- package/dist/cli/run.js +9 -0
- package/dist/index.js +1 -43
- package/dist/persistence/store.d.ts +1 -1
- package/dist/policy/approval.d.ts +27 -6
- package/dist/policy/approval.js +36 -8
- package/dist/policy/engine.d.ts +13 -0
- package/dist/policy/engine.js +28 -0
- package/dist/policy/patterns.d.ts +16 -0
- package/dist/policy/patterns.js +26 -0
- package/dist/tui/app.js +3 -1
- package/dist/tui/app.test.js +10 -0
- package/dist/tui/approval.d.ts +1 -1
- package/dist/tui/approval.js +2 -2
- package/dist/version.d.ts +8 -0
- package/dist/version.js +47 -0
- package/package.json +2 -2
package/READ.md
CHANGED
|
@@ -465,3 +465,71 @@ Global flags `src/index.ts:60` `--cwd/--config/--debug/--verbose/--quiet/--json/
|
|
|
465
465
|
|
|
466
466
|
Run: `pnpm build && pnpm test && node dist/index.js --help` → `klyro -p "fix login test" --output json` → `klyro doctor`.
|
|
467
467
|
|
|
468
|
+
---
|
|
469
|
+
|
|
470
|
+
## 20. Session Ledger v0.1.39 → v0.1.61 (what was done, what failed)
|
|
471
|
+
|
|
472
|
+
All work below was done live against user-reported production issues, verified with
|
|
473
|
+
`typecheck 0` + full `vitest` + built-`dist` smoke tests before every publish.
|
|
474
|
+
Current: **`klyro@0.1.61` `latest`, 51 test files, 442/442 tests, commits as
|
|
475
|
+
`siddu-lingampelli`** (history rewritten from `Mythicai-lab`, tags moved, §20.6).
|
|
476
|
+
|
|
477
|
+
### 20.1 Release history
|
|
478
|
+
|
|
479
|
+
| Version | Commit | What shipped |
|
|
480
|
+
|---|---|---|
|
|
481
|
+
| 0.1.39 | base | Single-column Klyro TUI, true `#E8843C` orange via 24-bit, hello Direct-LLM (no tools) |
|
|
482
|
+
| 0.1.40 | `ca85d56` | True `#FF6B1A` orange, clean single-column, no `g`-letter corruption |
|
|
483
|
+
| 0.1.41 | `1884807` | `useChatScroll` hook (pin/unpin, snap-to-message, new-messages pill) |
|
|
484
|
+
| 0.1.42 | `50c84dc` | Audit batch: git-blame argv+path validation, `NODE_OPTIONS`/`LD_PRELOAD` block, ordered transcript, configurable `rerunOnce` timeout, model-aware `estimateCost`, Buffer-based (non-O(n²)) verify collectors, deleted empty `packages/cli+core`, `snippets.ts`, `util/log.ts`, `stuckCount`; TUI ellipsis `…`→`...`, mojibake purge |
|
|
485
|
+
| 0.1.43 | `04bd24e` | Slash groundwork: `/provider` `/effort` `/model` `/compact` `/plan` `/login` `/logout` `/init` handlers |
|
|
486
|
+
| 0.1.44 | `81d9627` | **Priority 1 per `commands.md`** (~39 cmds): session/model/project/perms/auth groups; TUI orphan-file reconciliation (deleted `app.inline/banner/input-box/activity-line/thinking-block/components/Header`) |
|
|
487
|
+
| 0.1.45 | `feb30ff` | **Priority 2 per `commands.md`** (~70 cmds): review/build/git/files/agents/config/misc; `/` autocomplete popup + Tab |
|
|
488
|
+
| 0.1.46 | `b8947d1` | **`scroll.md` S1–S9 Ink adaptation**: `measure.ts` (display-width/wrap/cache/line-index), `scroll-model.ts` (anchor reducer), input history + contextual ↑/↓, run-length activity merge, console-patch, exit transcript replay |
|
|
489
|
+
| 0.1.47 | `c440bc3` | **Dead scroll trap fix** (directional epsilon — line/wheel up from bottom could never escape) + mouse-wheel capture + `Ctrl+F/B` pager keys |
|
|
490
|
+
| 0.1.48 | `1e7f85c` | **Set-once auth**: persisted provider resolution (env → config+credentials → probe), first-run setup, `/model` `/provider` persist, `/reload` rebuilds adapter |
|
|
491
|
+
| 0.1.49 | `f0e840d` + `262b8fc` | Plain-HTTP endpoints explain+confirm with persisted `allowInsecure` opt-in, loud rejections (`lastProviderError`), prefilled setup, keep-saved-key; (+ separate commit preserving a concurrent working-tree `scroll.md` rewrite) |
|
|
492
|
+
| 0.1.50 | `5b3d3ee` | **`design.md` conformance**: half-page keys, Esc/Ctrl+C/Shift+Enter semantics, full Markdown renderer, header/statusbar/plan restyle |
|
|
493
|
+
| 0.1.51 | `6ba47de` | **`scroll.md` command architecture**: `transcript-commands.ts` keybind mapper + 4-command handle, submit-key settle effect |
|
|
494
|
+
| 0.1.52 | `db09e5f` | **Markdown columns bug**: `MarkdownText` fragment rendered lines as side-by-side columns — back to single `<Text>` |
|
|
495
|
+
| 0.1.53 | `5f9fa84` | Root `overflow:hidden` I1 guard (input can never be pushed out), tool results patch start-items in place (no forever-`running` spinners), narrow-terminal hints |
|
|
496
|
+
| 0.1.54 | `1ec3d0c` | **Full audit application** (P0+P1, §20.3) |
|
|
497
|
+
| 0.1.55 | `eb8776f` | Live working spinner (Thinking row, running tool groups, status bar) |
|
|
498
|
+
| 0.1.56 | `cf1a94d` | **Thinking channel**: `reasoning_content`/Anthropic-thinking capture → dim display → cleared on response |
|
|
499
|
+
| 0.1.57 | `f6fd749` | Composer audit: `flexShrink={0}` chrome, position/multiline/aggregation tests |
|
|
500
|
+
| 0.1.58 | `a57d526` | Abort clears thinking, `/clear` resets scroll anchor, badge overlap fix |
|
|
501
|
+
| 0.1.59 | `bbe9ba6` | Scroll position readout (`⇅ top/max`) in status bar |
|
|
502
|
+
| 0.1.60 | `6627b5b` | **Wheel tap fix**: intercept `stdin.read` (Ink7 paused mode), not `data` events that never fire |
|
|
503
|
+
| 0.1.61 | `5a1a97f` | Brand sweep: Claude naming removed, `CLAUDE.md` fallback dropped (Klyro-only) |
|
|
504
|
+
|
|
505
|
+
### 20.2 What was built (by area)
|
|
506
|
+
|
|
507
|
+
- **Slash commands (113, all smoke-tested on built `dist`)**: session (`/new` `/resume` `/sessions` `/rename` `/fork` `/branch` `/export` `/copy`), model (`/model` `/models` `/provider` `/effort` `/fast`), project (`/init` `/status` `/context` `/diff` `/plan` `/todos` `/memory`), perms (`/permissions` `/mode` `/sandbox` `/approve` `/deny`), app (`/login` `/logout` `/auth` `/version` `/update` `/cancel` `/shell` `!` `/mention` `@` `/tools`), workflow (`/review` `/code-review` `/security-review` `/simplify` `/test` `/lint` `/build` `/run` `/fix` `/explain` `/format` `/ask`), checkpoints (`/undo` `/redo`→honest-noop `/rewind` `/checkpoint` `/accept` `/reject`), activity (`/details` `/verbose` `/raw` `/activity` `/tasks` `/ps` `/stop` `/queue` `/retry` `/kill`), agents+MCP, files (`/attach`→in-context `/drop` `/files` `/image` `/paste` `/ls` `/tree` `/search` `/web` `/read` `/map` `/tokens`), git (`/commit` via temp file `/push` `/pull` `/pr` `/issue`), config UI (`/editor` argv-spawn `/keymap` `/vim` `/theme` `/statusline` `/output-style`), diagnostics (`/doctor` `/debug` `/whoami` `/reload` `/reset` `/bug` `/changelog`), library (`/prompt` `/alias`→auto-expand `/commands` `/env` (injection keys blocked) `/deps` `/install`).
|
|
508
|
+
- **Scroll system** (`src/tui/scroll-model.ts` + `measure.ts` + `mouse.ts` + `transcript-commands.ts`): anchor `{itemId,lineInItem}` (never raw rows), measured wrapped display lines (CJK=2/emoji=2/combining=0), `MeasureCache`, follow-at-bottom / freeze-when-pinned / `↓ N new` badge, `End`/`Ctrl+G`/empty-Enter dismiss, wheel ±3, PageUp/Dn half-page, Home/End first/last, exit transcript replay, degraded tiny-terminal mode.
|
|
509
|
+
- **Auth persistence**: `~/.klyro/settings.json` + `credentials.json` (0600); resolution env → config → stored keys → local probe; `KLYRO_CONFIG`/`KLYRO_CONFIG_DIR`/`KLYRO_CREDENTIALS_FILE` overrides (also used by tests); persisted `allowInsecure` replaces per-terminal `KLYRO_ALLOW_INSECURE=1`.
|
|
510
|
+
- **Verification**: denylist (destructive-only; metachars allowed since commands are user/config-sourced), sanity checks fail-fast *before* the suite, scoped→full, flaky rerun with configured timeout, `hasEdits` drives `--require-verify` (exit 8 only when edits exist).
|
|
511
|
+
- **Safety**: `resolveAndFollowSymlinks` everywhere incl. checkpoints (traversal-contained undo that also restores deletions), `wasRead` guards on all four mutators with normalized + per-task history, `readFileSizeRule` stats real files, `clonePolicyConfig` per engine, `setByPath` proto-pollution guard, shell full-output caps + POSIX process-group kill, `startBackgroundArgv` (shell:false), secret redaction before model/summary paths.
|
|
512
|
+
|
|
513
|
+
### 20.3 Audit application (the big diagnostic)
|
|
514
|
+
|
|
515
|
+
Every claim was verified against code before touching. **Confirmed + fixed (P0)**: ApprovalModal never rendered (+ single-listener bridge deafened it — also the flake root cause); Anthropic `role:tool` flattened to empty turns; duplicate `message_end`; edit-file fuzzy rewriting whole files (plus a dead line-window tier); apply-patch append-only stub → real hunk applier; `require()` in ESM (`run-verify`, later all touched files → static imports). **Confirmed + fixed (P1)**: shared policy config; `inferFileChanged` gaps; tokenizer pair orphans; klyro-md import escape + no caps; verify-order waste + `$()` overblock; unbounded full buffers; `/web` caps; checkpoint traversal/undo; store locking + `delete()`/`fork()` with messages; SIGINT-once; silent-mode newline; retry sleep ignores abort; read-history normalization; dotfile flag; `read_fileBody` shape bug in `/attach` `/mention` `/read` `/review` `/explain`; Ctrl+C-safe login/setup; `isToolErrorCode` swallowing custom codes; read-size rule via stat; multi-edit guards; `last.diff` writer; dry-run real prompt; debug.log rotation; `/cd` cwd reset; cost/context single source; headless URL policy. **Deliberately not changed**: `isSimpleChat` fast-path (intended UX); Ink-vs-OpenTUI architecture (approved); eval/MCP/L10–L20 (roadmap); `$EDITOR`/user-typed shell passthrough (user's own intent); trace paths; `isToolErrorCode`-adjacent churn that worked.
|
|
516
|
+
|
|
517
|
+
### 20.4 What FAILED along the way (honest log)
|
|
518
|
+
|
|
519
|
+
- **Markdown columns regression (shipped 0.1.50, caught by user screenshot, fixed 0.1.52)**: fragment of `<Text>` in a row `Box` laid lines out as columns. Lesson: frame-level regression test added (fails on old code, passes on new).
|
|
520
|
+
- **Dead scroll trap (mine)**: symmetric follow-epsilon made line/wheel-up from bottom re-stick forever. Found by tracing, fixed directional, regression-tested.
|
|
521
|
+
- **Deaf wheel tap (mine)**: intercepted `data` events; Ink7 only uses `readable`+`read()`. Found by reading installed Ink source, rewrote + tested the wrapper contract.
|
|
522
|
+
- **Silent setup loop (mine)**: saved-but-rejected base URLs were swallowed → infinite first-run. Fixed with recorded reasons + prefilled recovery.
|
|
523
|
+
- **`require()` in ESM**: would have thrown `ReferenceError` in production `dist` while passing under `tsx`/`vitest` shims. Converted everything touched to static imports.
|
|
524
|
+
- **My own test-math errors**: indent tier (4sp→2 tabs), wheel maxTop, badge line counts, half-page expectations — each caught by running tests, fixed, and (for scroll-reset) proven discriminating by noop-check.
|
|
525
|
+
- **Infra friction**: npm `E409` staging + `E404` propagation delays (60–150s waits per publish); PowerShell mangling inline `python`/`node` quoting (rule learned: script files only); `git add -A` twice scooped a concurrent agent's doc rewrites (split into separate commits; own files only since); vitest load flakes under parallel sessions (fixed sleeps → poll-with-timeout; one residual publish-gate flake at 0.1.59, green on rerun).
|
|
526
|
+
- **Authorship**: 49 commits + 10 tags rewritten to `siddu-lingampelli` (force-with-lease, trees verified identical); repo-local git identity set.
|
|
527
|
+
|
|
528
|
+
### 20.5 Environment notes for future sessions
|
|
529
|
+
|
|
530
|
+
- Windows PowerShell 5.1: chain with `;`, never `&&`; quote paths; never inline `python -c` with brackets/quotes (write temp scripts under `C:\Users\L.Siddhartha\AppData\Local\Temp\opencode`).
|
|
531
|
+
- Never `git add -A` blindly — another agent rewrites `design.md`/`scroll.md`/`TUI_DESIGN.md`/`commands.md` concurrently; stage own files explicitly.
|
|
532
|
+
- `npm publish` runs `typecheck && test && build`; expect CDN delays; `latest` flips ~1–3 min after `+ klyro@x`.
|
|
533
|
+
- Test isolation for anything reading `~/.klyro`: set `KLYRO_CONFIG` + `KLYRO_CONFIG_DIR` + `KLYRO_CREDENTIALS_FILE` to temp dirs.
|
|
534
|
+
- `ink-testing-library` has fixed terminal geometry (no resize tests) and normalizes keys like production Ink; production-only paths (stdin tap, alt-screen) need contract tests, not frame tests.
|
|
535
|
+
|
|
@@ -8,14 +8,15 @@
|
|
|
8
8
|
* 1. `system` is a top-level field, not a message with role=system.
|
|
9
9
|
* 2. Tool definitions use `input_schema` not `parameters`, and have no
|
|
10
10
|
* `type: 'function'` wrapper.
|
|
11
|
-
* 3. `tool_use_id` becomes our `id`;
|
|
12
|
-
* `input_json_delta`
|
|
11
|
+
* 3. `tool_use_id` becomes our `id`; tool input arrives fragmented across
|
|
12
|
+
* `input_json_delta` frames, assembled per content_block index (never
|
|
13
|
+
* assumed whole, never silently dropped).
|
|
13
14
|
*
|
|
14
15
|
* Auth: `x-api-key: <key>`. Version header is sent as `anthropic-version`.
|
|
15
16
|
* Auth can be a Bearer token (for proxies) — the adapter accepts either.
|
|
16
17
|
*/
|
|
17
18
|
import type { Message } from './message.js';
|
|
18
|
-
import type { ProviderAdapter, ToolDefinition } from './provider-adapter.js';
|
|
19
|
+
import type { ProviderAdapter, StreamEvent, ToolDefinition } from './provider-adapter.js';
|
|
19
20
|
export interface AnthropicAdapterOptions {
|
|
20
21
|
baseURL?: string;
|
|
21
22
|
apiKey: string;
|
|
@@ -50,17 +51,37 @@ interface AnthropicMessage {
|
|
|
50
51
|
is_error?: boolean;
|
|
51
52
|
}>;
|
|
52
53
|
}
|
|
54
|
+
interface AnthropicSseEvent {
|
|
55
|
+
type: string;
|
|
56
|
+
[key: string]: unknown;
|
|
57
|
+
}
|
|
53
58
|
export declare class AnthropicApiError extends Error {
|
|
54
59
|
readonly status: number;
|
|
55
60
|
readonly body: string;
|
|
56
61
|
constructor(status: number, body: string);
|
|
57
62
|
}
|
|
58
63
|
export declare function anthropicAdapter(opts: AnthropicAdapterOptions): ProviderAdapter;
|
|
59
|
-
/**
|
|
60
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
+
/**
|
|
65
|
+
* Mutable per-stream assembly state. Blocks are keyed by content_block
|
|
66
|
+
* index; the tool id is carried inside the block entry. There is no global
|
|
67
|
+
* "current tool" — concurrent or interleaved blocks stay correctly routed.
|
|
68
|
+
*/
|
|
69
|
+
interface AnthropicStreamState {
|
|
70
|
+
blocks: Map<number, {
|
|
71
|
+
id: string;
|
|
72
|
+
name: string;
|
|
73
|
+
argsJson: string;
|
|
74
|
+
open: boolean;
|
|
75
|
+
}>;
|
|
76
|
+
/** Fragments for an index with no open block yet (flushed on block start). */
|
|
77
|
+
orphans: Map<number, string>;
|
|
78
|
+
thinkingIdx: number | null;
|
|
79
|
+
usage: {
|
|
80
|
+
input?: number;
|
|
81
|
+
output?: number;
|
|
82
|
+
};
|
|
83
|
+
}
|
|
84
|
+
declare function translateSse(event: string, parsed: AnthropicSseEvent, state: AnthropicStreamState): StreamEvent[];
|
|
64
85
|
declare function toAnthropicMessages(messages: Message[]): AnthropicMessage[];
|
|
65
86
|
declare function toAnthropicTool(t: ToolDefinition): {
|
|
66
87
|
name: string;
|
|
@@ -70,6 +91,6 @@ declare function toAnthropicTool(t: ToolDefinition): {
|
|
|
70
91
|
export declare const _internal: {
|
|
71
92
|
toAnthropicMessages: typeof toAnthropicMessages;
|
|
72
93
|
toAnthropicTool: typeof toAnthropicTool;
|
|
73
|
-
|
|
94
|
+
translateSse: typeof translateSse;
|
|
74
95
|
};
|
|
75
96
|
export {};
|
|
@@ -8,8 +8,9 @@
|
|
|
8
8
|
* 1. `system` is a top-level field, not a message with role=system.
|
|
9
9
|
* 2. Tool definitions use `input_schema` not `parameters`, and have no
|
|
10
10
|
* `type: 'function'` wrapper.
|
|
11
|
-
* 3. `tool_use_id` becomes our `id`;
|
|
12
|
-
* `input_json_delta`
|
|
11
|
+
* 3. `tool_use_id` becomes our `id`; tool input arrives fragmented across
|
|
12
|
+
* `input_json_delta` frames, assembled per content_block index (never
|
|
13
|
+
* assumed whole, never silently dropped).
|
|
13
14
|
*
|
|
14
15
|
* Auth: `x-api-key: <key>`. Version header is sent as `anthropic-version`.
|
|
15
16
|
* Auth can be a Bearer token (for proxies) — the adapter accepts either.
|
|
@@ -111,12 +112,14 @@ async function* streamAnthropic(req, opts) {
|
|
|
111
112
|
const reader = resp.body.getReader();
|
|
112
113
|
const decoder = new TextDecoder('utf-8');
|
|
113
114
|
let buf = '';
|
|
114
|
-
//
|
|
115
|
-
|
|
116
|
-
|
|
117
|
-
|
|
118
|
-
|
|
119
|
-
|
|
115
|
+
// Per-block assembly keyed by content_block index (tool input IS
|
|
116
|
+
// fragmented across input_json_delta frames — never assume otherwise).
|
|
117
|
+
const state = {
|
|
118
|
+
blocks: new Map(),
|
|
119
|
+
orphans: new Map(),
|
|
120
|
+
thinkingIdx: null,
|
|
121
|
+
usage: {},
|
|
122
|
+
};
|
|
120
123
|
// message_stop already yields message_end — don't emit a second one at EOF.
|
|
121
124
|
let sawMessageEnd = false;
|
|
122
125
|
try {
|
|
@@ -155,7 +158,7 @@ async function* streamAnthropic(req, opts) {
|
|
|
155
158
|
catch {
|
|
156
159
|
continue;
|
|
157
160
|
}
|
|
158
|
-
const out = translateSse(e.event, parsed,
|
|
161
|
+
const out = translateSse(e.event, parsed, state);
|
|
159
162
|
for (const ev of out) {
|
|
160
163
|
if (ev.kind === 'message_end')
|
|
161
164
|
sawMessageEnd = true;
|
|
@@ -172,23 +175,69 @@ async function* streamAnthropic(req, opts) {
|
|
|
172
175
|
finally {
|
|
173
176
|
reader.releaseLock();
|
|
174
177
|
}
|
|
175
|
-
|
|
176
|
-
|
|
178
|
+
// Truncated stream: close open blocks so the runtime finalizes them as
|
|
179
|
+
// (malformed) structured errors instead of hanging, then terminate.
|
|
180
|
+
for (const b of state.blocks.values()) {
|
|
181
|
+
if (b.open) {
|
|
182
|
+
b.open = false;
|
|
183
|
+
yield { kind: 'tool_call_end', id: b.id };
|
|
184
|
+
}
|
|
185
|
+
}
|
|
186
|
+
state.blocks.clear();
|
|
187
|
+
// Fragments that could never be attributed to a tool block are a stream
|
|
188
|
+
// integrity failure — surface loudly, never silently drop.
|
|
189
|
+
if (state.orphans.size > 0 && !sawMessageEnd) {
|
|
190
|
+
const count = [...state.orphans.values()].reduce((n, s) => n + s.length, 0);
|
|
191
|
+
state.orphans.clear();
|
|
192
|
+
yield {
|
|
193
|
+
kind: 'error',
|
|
194
|
+
code: 'ORPHAN_TOOL_DELTAS',
|
|
195
|
+
message: `stream ended with ${count} chars of tool input that match no content block`,
|
|
196
|
+
retryable: false,
|
|
197
|
+
};
|
|
198
|
+
return;
|
|
199
|
+
}
|
|
200
|
+
state.orphans.clear();
|
|
201
|
+
if (!sawMessageEnd) {
|
|
202
|
+
yield {
|
|
203
|
+
kind: 'message_end',
|
|
204
|
+
finishReason: 'stop',
|
|
205
|
+
...(state.usage.input !== undefined || state.usage.output !== undefined
|
|
206
|
+
? { usage: { input: state.usage.input ?? 0, output: state.usage.output ?? 0 } }
|
|
207
|
+
: {}),
|
|
208
|
+
};
|
|
209
|
+
}
|
|
177
210
|
}
|
|
178
|
-
function translateSse(event, parsed,
|
|
211
|
+
function translateSse(event, parsed, state) {
|
|
179
212
|
const out = [];
|
|
180
213
|
switch (event) {
|
|
214
|
+
case 'message_start': {
|
|
215
|
+
const usage = parsed.message?.usage;
|
|
216
|
+
if (typeof usage?.input_tokens === 'number')
|
|
217
|
+
state.usage.input = usage.input_tokens;
|
|
218
|
+
return out;
|
|
219
|
+
}
|
|
220
|
+
case 'message_delta': {
|
|
221
|
+
const usage = parsed.usage;
|
|
222
|
+
if (typeof usage?.output_tokens === 'number') {
|
|
223
|
+
state.usage.output = (state.usage.output ?? 0) + usage.output_tokens;
|
|
224
|
+
}
|
|
225
|
+
return out;
|
|
226
|
+
}
|
|
181
227
|
case 'content_block_start': {
|
|
182
228
|
const block = parsed.content_block;
|
|
183
229
|
const idx = parsed.index;
|
|
184
|
-
if (block?.type === 'tool_use' && block.id && block.name) {
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
|
|
230
|
+
if (block?.type === 'tool_use' && block.id && block.name && idx !== undefined) {
|
|
231
|
+
// Flush any fragments that arrived before the block start.
|
|
232
|
+
const stashed = state.orphans.get(idx);
|
|
233
|
+
state.orphans.delete(idx);
|
|
234
|
+
state.blocks.set(idx, { id: block.id, name: block.name, argsJson: stashed ?? '', open: true });
|
|
188
235
|
out.push({ kind: 'tool_call_start', id: block.id, name: block.name });
|
|
236
|
+
if (stashed)
|
|
237
|
+
out.push({ kind: 'tool_call_delta', id: block.id, argsJson: stashed });
|
|
189
238
|
}
|
|
190
|
-
else if ((block?.type === 'thinking' || block?.type === 'redacted_thinking') &&
|
|
191
|
-
|
|
239
|
+
else if ((block?.type === 'thinking' || block?.type === 'redacted_thinking') && idx !== undefined) {
|
|
240
|
+
state.thinkingIdx = idx;
|
|
192
241
|
}
|
|
193
242
|
return out;
|
|
194
243
|
}
|
|
@@ -202,31 +251,50 @@ function translateSse(event, parsed, toolBuffers, indexToToolId, thinking) {
|
|
|
202
251
|
out.push({ kind: 'thinking_delta', text: delta.thinking });
|
|
203
252
|
}
|
|
204
253
|
else if (delta?.type === 'input_json_delta' && typeof delta.partial_json === 'string') {
|
|
205
|
-
const
|
|
206
|
-
if (
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
254
|
+
const entry = index !== undefined ? state.blocks.get(index) : undefined;
|
|
255
|
+
if (entry && entry.open) {
|
|
256
|
+
entry.argsJson += delta.partial_json;
|
|
257
|
+
out.push({ kind: 'tool_call_delta', id: entry.id, argsJson: delta.partial_json });
|
|
258
|
+
}
|
|
259
|
+
else if (index !== undefined) {
|
|
260
|
+
const open = [...state.blocks.values()].filter((b) => b.open);
|
|
261
|
+
if (open.length === 1) {
|
|
262
|
+
// Single in-flight tool: attribute here (documented heuristic).
|
|
263
|
+
open[0].argsJson += delta.partial_json;
|
|
264
|
+
out.push({ kind: 'tool_call_delta', id: open[0].id, argsJson: delta.partial_json });
|
|
265
|
+
}
|
|
266
|
+
else {
|
|
267
|
+
// No safe attribution — stash for a later block start, or
|
|
268
|
+
// surface as ORPHAN_TOOL_DELTAS at stream end. Never drop.
|
|
269
|
+
state.orphans.set(index, (state.orphans.get(index) ?? '') + delta.partial_json);
|
|
211
270
|
}
|
|
212
271
|
}
|
|
272
|
+
else {
|
|
273
|
+
state.orphans.set(-1, (state.orphans.get(-1) ?? '') + delta.partial_json);
|
|
274
|
+
}
|
|
213
275
|
}
|
|
214
276
|
return out;
|
|
215
277
|
}
|
|
216
278
|
case 'content_block_stop': {
|
|
217
279
|
const index = parsed.index;
|
|
218
|
-
if (
|
|
219
|
-
|
|
220
|
-
const
|
|
221
|
-
if (
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
out.push({ kind: 'tool_call_end', id });
|
|
280
|
+
if (index !== undefined && index === state.thinkingIdx)
|
|
281
|
+
state.thinkingIdx = null;
|
|
282
|
+
const entry = index !== undefined ? state.blocks.get(index) : undefined;
|
|
283
|
+
if (entry && entry.open) {
|
|
284
|
+
entry.open = false;
|
|
285
|
+
state.blocks.delete(index);
|
|
286
|
+
out.push({ kind: 'tool_call_end', id: entry.id });
|
|
225
287
|
}
|
|
226
288
|
return out;
|
|
227
289
|
}
|
|
228
290
|
case 'message_stop': {
|
|
229
|
-
out.push({
|
|
291
|
+
out.push({
|
|
292
|
+
kind: 'message_end',
|
|
293
|
+
finishReason: 'stop',
|
|
294
|
+
...(state.usage.input !== undefined || state.usage.output !== undefined
|
|
295
|
+
? { usage: { input: state.usage.input ?? 0, output: state.usage.output ?? 0 } }
|
|
296
|
+
: {}),
|
|
297
|
+
});
|
|
230
298
|
return out;
|
|
231
299
|
}
|
|
232
300
|
case 'error': {
|
|
@@ -243,21 +311,6 @@ function translateSse(event, parsed, toolBuffers, indexToToolId, thinking) {
|
|
|
243
311
|
return out;
|
|
244
312
|
}
|
|
245
313
|
}
|
|
246
|
-
/** Match an Anthropic content_block index to the tool_use id we emitted. */
|
|
247
|
-
function findToolIdByIndex(index, buffers, indexToToolId) {
|
|
248
|
-
if (index === undefined)
|
|
249
|
-
return undefined;
|
|
250
|
-
if (indexToToolId) {
|
|
251
|
-
const direct = indexToToolId.get(index);
|
|
252
|
-
if (direct)
|
|
253
|
-
return direct;
|
|
254
|
-
}
|
|
255
|
-
// Single-buffer fallback: if only one in-flight tool, any delta belongs to it
|
|
256
|
-
if (buffers.size === 1)
|
|
257
|
-
return buffers.keys().next().value;
|
|
258
|
-
// No reliable mapping — drop the delta rather than misroute to wrong tool (prevents _parse_error loops)
|
|
259
|
-
return undefined;
|
|
260
|
-
}
|
|
261
314
|
function toAnthropicMessages(messages) {
|
|
262
315
|
return messages.map((m) => {
|
|
263
316
|
if (m.role === 'user') {
|
|
@@ -320,4 +373,4 @@ function toAnthropicTool(t) {
|
|
|
320
373
|
};
|
|
321
374
|
}
|
|
322
375
|
// Re-export for testability.
|
|
323
|
-
export const _internal = { toAnthropicMessages, toAnthropicTool,
|
|
376
|
+
export const _internal = { toAnthropicMessages, toAnthropicTool, translateSse };
|
|
@@ -195,10 +195,41 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
|
|
|
195
195
|
const reader = res.body.getReader();
|
|
196
196
|
const decoder = new TextDecoder();
|
|
197
197
|
let buf = '';
|
|
198
|
-
|
|
199
|
-
const toolIds = new Map();
|
|
200
|
-
const toolNames = new Map();
|
|
198
|
+
const blocks = new Map();
|
|
201
199
|
let pendingUsage;
|
|
200
|
+
// Canonical contract: exactly one terminal event per stream.
|
|
201
|
+
let terminalEmitted = false;
|
|
202
|
+
function* emitTerminal(finishReason, usage) {
|
|
203
|
+
if (terminalEmitted)
|
|
204
|
+
return;
|
|
205
|
+
terminalEmitted = true;
|
|
206
|
+
for (const b of blocks.values()) {
|
|
207
|
+
if (b.started && !b.ended && b.id) {
|
|
208
|
+
b.ended = true;
|
|
209
|
+
yield { kind: 'tool_call_end', id: b.id };
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
// Fragments that never gained an identity are surfaced as incomplete
|
|
213
|
+
// calls (the runtime turns them into structured errors) — never dropped.
|
|
214
|
+
let incomplete = 0;
|
|
215
|
+
for (const b of blocks.values()) {
|
|
216
|
+
if (!b.started && (b.argsJson || b.id || b.name)) {
|
|
217
|
+
const id = b.id ?? `incomplete_${incomplete++}`;
|
|
218
|
+
yield { kind: 'tool_call_start', id, name: b.name ?? 'unknown' };
|
|
219
|
+
if (b.argsJson)
|
|
220
|
+
yield { kind: 'tool_call_delta', id, argsJson: b.argsJson };
|
|
221
|
+
yield { kind: 'tool_call_end', id };
|
|
222
|
+
b.started = true;
|
|
223
|
+
b.ended = true;
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
const end = { kind: 'message_end' };
|
|
227
|
+
if (finishReason)
|
|
228
|
+
end.finishReason = finishReason;
|
|
229
|
+
if (usage)
|
|
230
|
+
end.usage = usage;
|
|
231
|
+
yield end;
|
|
232
|
+
}
|
|
202
233
|
try {
|
|
203
234
|
while (true) {
|
|
204
235
|
const { value, done } = await reader.read();
|
|
@@ -215,7 +246,7 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
|
|
|
215
246
|
continue;
|
|
216
247
|
const data = line.slice(5).trim();
|
|
217
248
|
if (data === '[DONE]') {
|
|
218
|
-
yield
|
|
249
|
+
yield* emitTerminal(undefined, pendingUsage);
|
|
219
250
|
return;
|
|
220
251
|
}
|
|
221
252
|
let chunk;
|
|
@@ -248,17 +279,28 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
|
|
|
248
279
|
yield { kind: 'thinking_delta', text: thinking };
|
|
249
280
|
}
|
|
250
281
|
for (const tc of choice.delta.tool_calls ?? []) {
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
282
|
+
let b = blocks.get(tc.index);
|
|
283
|
+
if (!b) {
|
|
284
|
+
b = { argsJson: '', started: false, ended: false };
|
|
285
|
+
blocks.set(tc.index, b);
|
|
255
286
|
}
|
|
256
|
-
|
|
257
|
-
|
|
287
|
+
if (tc.id)
|
|
288
|
+
b.id = tc.id;
|
|
289
|
+
if (tc.function?.name)
|
|
290
|
+
b.name = tc.function.name;
|
|
291
|
+
const newArgs = tc.function?.arguments;
|
|
292
|
+
if (newArgs)
|
|
293
|
+
b.argsJson += newArgs;
|
|
294
|
+
if (b.id && b.name && !b.started) {
|
|
295
|
+
// Identity arrived (possibly after earlier fragments). Emit
|
|
296
|
+
// start, then flush any accumulated args as one delta.
|
|
297
|
+
b.started = true;
|
|
298
|
+
yield { kind: 'tool_call_start', id: b.id, name: b.name };
|
|
299
|
+
if (b.argsJson)
|
|
300
|
+
yield { kind: 'tool_call_delta', id: b.id, argsJson: b.argsJson };
|
|
258
301
|
}
|
|
259
|
-
if (
|
|
260
|
-
|
|
261
|
-
yield { kind: 'tool_call_delta', id, argsJson: tc.function.arguments };
|
|
302
|
+
else if (b.started && b.id && newArgs) {
|
|
303
|
+
yield { kind: 'tool_call_delta', id: b.id, argsJson: newArgs };
|
|
262
304
|
}
|
|
263
305
|
}
|
|
264
306
|
if (choice.finish_reason) {
|
|
@@ -266,38 +308,19 @@ async function* streamChatCompletions(url, opts, req, fetchImpl) {
|
|
|
266
308
|
? { input: chunk.usage.prompt_tokens, output: chunk.usage.completion_tokens }
|
|
267
309
|
: undefined);
|
|
268
310
|
pendingUsage = undefined;
|
|
269
|
-
|
|
270
|
-
const ids = [...toolIds.values()];
|
|
271
|
-
toolIds.clear();
|
|
272
|
-
toolNames.clear();
|
|
273
|
-
for (const id of ids)
|
|
274
|
-
yield { kind: 'tool_call_end', id };
|
|
275
|
-
yield { kind: 'message_end', finishReason: choice.finish_reason, usage };
|
|
311
|
+
yield* emitTerminal(choice.finish_reason, usage);
|
|
276
312
|
}
|
|
277
313
|
}
|
|
278
314
|
}
|
|
279
315
|
}
|
|
280
316
|
}
|
|
281
|
-
|
|
282
|
-
for (const id of toolIds.values())
|
|
283
|
-
yield { kind: 'tool_call_end', id };
|
|
284
|
-
if (pendingUsage) {
|
|
285
|
-
yield { kind: 'message_end', usage: pendingUsage };
|
|
286
|
-
}
|
|
287
|
-
else {
|
|
288
|
-
yield { kind: 'message_end' };
|
|
289
|
-
}
|
|
290
|
-
}
|
|
291
|
-
else if (pendingUsage) {
|
|
292
|
-
yield { kind: 'message_end', usage: pendingUsage };
|
|
293
|
-
}
|
|
294
|
-
else {
|
|
295
|
-
yield { kind: 'message_end' };
|
|
296
|
-
}
|
|
317
|
+
yield* emitTerminal(undefined, pendingUsage);
|
|
297
318
|
}
|
|
298
319
|
catch (err) {
|
|
299
|
-
|
|
300
|
-
|
|
320
|
+
if (!terminalEmitted) {
|
|
321
|
+
const msg = err instanceof Error ? err.message : String(err);
|
|
322
|
+
yield { kind: 'error', code: 'STREAM', message: msg, retryable: true };
|
|
323
|
+
}
|
|
301
324
|
}
|
|
302
325
|
finally {
|
|
303
326
|
clearTimeout(timer);
|
package/dist/agent/runtime.d.ts
CHANGED
|
@@ -139,6 +139,7 @@ export type RuntimeEvent = {
|
|
|
139
139
|
kind: 'usage';
|
|
140
140
|
input: number;
|
|
141
141
|
output: number;
|
|
142
|
+
estimated?: boolean;
|
|
142
143
|
} | {
|
|
143
144
|
kind: 'final_text';
|
|
144
145
|
text: string;
|
|
@@ -171,7 +172,7 @@ export type RuntimeEvent = {
|
|
|
171
172
|
sessionId: string;
|
|
172
173
|
};
|
|
173
174
|
export interface RunResult {
|
|
174
|
-
status: 'complete' | 'max_steps' | 'aborted' | 'no_final' | 'verify_failed' | 'limit' | 'blocked';
|
|
175
|
+
status: 'complete' | 'max_steps' | 'aborted' | 'no_final' | 'verify_failed' | 'limit' | 'blocked' | 'stuck';
|
|
175
176
|
steps: number;
|
|
176
177
|
toolCalls: number;
|
|
177
178
|
finalText: string;
|
|
@@ -181,6 +182,7 @@ export interface RunResult {
|
|
|
181
182
|
usage: {
|
|
182
183
|
input: number;
|
|
183
184
|
output: number;
|
|
185
|
+
estimated?: boolean;
|
|
184
186
|
};
|
|
185
187
|
/** Number of policy-driven user prompts the user accepted. */
|
|
186
188
|
repairs?: number;
|