@spunto/build 0.21.1 → 0.23.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 +32 -0
- package/package.json +9 -2
- package/src/agent-contract/agent-contract.v1.schema.json +672 -0
- package/src/agent-contract/conformance.ts +315 -0
- package/src/agent-contract/examples/fake-agent.mjs +46 -0
- package/src/agent-contract/index.ts +20 -0
- package/src/agent-contract/vocabulary.ts +313 -0
- package/src/agent-runners/catalogs.ts +7 -0
- package/src/agent-runners/claude/adapter.ts +330 -0
- package/src/agent-runners/claude.mjs +46 -17
- package/src/agent-runners/codex/adapter.ts +135 -0
- package/src/agent-runners/codex.mjs +15 -7
- package/src/agent-runners/index.ts +24 -15
- package/src/agent-stream/agent-stream.ts +45 -430
- package/src/agent-stream/draft.ts +156 -0
- package/src/agent-stream/index.ts +3 -0
|
@@ -0,0 +1,156 @@
|
|
|
1
|
+
// What the agent is writing *right now*, before it has finished saying it.
|
|
2
|
+
//
|
|
3
|
+
// The vocabulary (`agent-stream.ts`) is made of finished facts: a message lands once, whole. A
|
|
4
|
+
// harness that streams tokens says the same message a second time, piecemeal, ahead of it —
|
|
5
|
+
// Claude's `stream_event` lines (`includePartialMessages` in the Agent SDK,
|
|
6
|
+
// `--include-partial-messages` on the CLI). Those pieces are **not events**: storing one row per
|
|
7
|
+
// token would bury a session under thousands of rows that the finished message then repeats in
|
|
8
|
+
// full. So they fold into one transient value instead — the draft — which the finished message
|
|
9
|
+
// replaces the moment it lands, and which nothing persists.
|
|
10
|
+
//
|
|
11
|
+
// Pure like the rest of the package: lines in, draft out. Where the draft is kept between two
|
|
12
|
+
// reads (memory, for the API) is the caller's business.
|
|
13
|
+
|
|
14
|
+
import type { AgentProtocol } from "./agent-stream"
|
|
15
|
+
|
|
16
|
+
/** The text block being written. `null` = nothing in flight. */
|
|
17
|
+
export type AgentDraft = { text: string; truncated: boolean }
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Same ceiling as a stored `message` (`clip` in `agent-stream.ts`): a draft that ran past what
|
|
21
|
+
* the finished message will keep would visibly shrink the instant it lands.
|
|
22
|
+
*/
|
|
23
|
+
const MAX_DRAFT = 8_000
|
|
24
|
+
|
|
25
|
+
function append(draft: AgentDraft | null, text: string): AgentDraft {
|
|
26
|
+
const base = draft ?? { text: "", truncated: false }
|
|
27
|
+
if (base.truncated) return base
|
|
28
|
+
const next = base.text + text
|
|
29
|
+
return next.length > MAX_DRAFT ? { text: next.slice(0, MAX_DRAFT), truncated: true } : { text: next, truncated: false }
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/** Claude's dialects print token deltas; Codex's prints the message so far on `item.updated`; the contract, `message.delta`. */
|
|
33
|
+
function streamsPartials(protocol: AgentProtocol): boolean {
|
|
34
|
+
return (
|
|
35
|
+
protocol === "agent-v1" ||
|
|
36
|
+
protocol === "claude-runner" ||
|
|
37
|
+
protocol === "claude-stream" ||
|
|
38
|
+
protocol === "claude-json" ||
|
|
39
|
+
protocol === "codex-runner"
|
|
40
|
+
)
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** What closes a draft in the contract: the finished text, or anything that comes after it in the turn. */
|
|
44
|
+
const CLOSES_DRAFT = new Set(["message", "thinking", "tool.call", "tool.result", "session.ended"])
|
|
45
|
+
|
|
46
|
+
/**
|
|
47
|
+
* The contract (`agent-v1`): `message.delta` with `start` opens a fresh draft, without it grows the
|
|
48
|
+
* one in flight. The runner already chose what is a draft (a subagent's words are not), so this
|
|
49
|
+
* only folds — whichever harness it was.
|
|
50
|
+
*/
|
|
51
|
+
function foldContract(lines: string[], draft: AgentDraft | null): AgentDraft | null {
|
|
52
|
+
let current = draft
|
|
53
|
+
for (const line of lines) {
|
|
54
|
+
const trimmed = line.trim()
|
|
55
|
+
if (!trimmed.startsWith("{")) continue
|
|
56
|
+
// Cheap pre-filter, before parsing a line that may carry megabytes.
|
|
57
|
+
if (!trimmed.includes('"message') && !trimmed.includes('"thinking"') && !trimmed.includes('"tool.') && !trimmed.includes('"session.ended"')) continue
|
|
58
|
+
let obj: Record<string, unknown>
|
|
59
|
+
try {
|
|
60
|
+
const parsed = JSON.parse(trimmed)
|
|
61
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue
|
|
62
|
+
obj = parsed as Record<string, unknown>
|
|
63
|
+
} catch {
|
|
64
|
+
continue
|
|
65
|
+
}
|
|
66
|
+
if (obj.type === "message.delta" && typeof obj.text === "string") {
|
|
67
|
+
current = append(obj.start === true ? null : current, obj.text)
|
|
68
|
+
} else if (typeof obj.type === "string" && CLOSES_DRAFT.has(obj.type)) {
|
|
69
|
+
current = null
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
return current
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Codex: an `agent_message` item carries its whole text so far, not a delta — `item.started` and
|
|
77
|
+
* `item.updated` *are* the draft, `item.completed` (stored as the `message`) or the end of the
|
|
78
|
+
* turn closes it. Whether the SDK sends updates of a message mid-write is its business; the runner
|
|
79
|
+
* prints every event it gets, so this costs nothing when it does not.
|
|
80
|
+
*/
|
|
81
|
+
function foldCodex(lines: string[], draft: AgentDraft | null): AgentDraft | null {
|
|
82
|
+
let current = draft
|
|
83
|
+
for (const line of lines) {
|
|
84
|
+
const trimmed = line.trim()
|
|
85
|
+
if (!trimmed.startsWith("{")) continue
|
|
86
|
+
if (!trimmed.includes('"agent_message"') && !trimmed.includes('"turn.')) continue
|
|
87
|
+
let obj: Record<string, unknown>
|
|
88
|
+
try {
|
|
89
|
+
const parsed = JSON.parse(trimmed)
|
|
90
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue
|
|
91
|
+
obj = parsed as Record<string, unknown>
|
|
92
|
+
} catch {
|
|
93
|
+
continue
|
|
94
|
+
}
|
|
95
|
+
if (obj.type === "turn.completed" || obj.type === "turn.failed") {
|
|
96
|
+
current = null
|
|
97
|
+
continue
|
|
98
|
+
}
|
|
99
|
+
const item = (obj.item ?? {}) as Record<string, unknown>
|
|
100
|
+
if (item.type !== "agent_message") continue
|
|
101
|
+
if (obj.type === "item.completed") current = null
|
|
102
|
+
else if ((obj.type === "item.started" || obj.type === "item.updated") && typeof item.text === "string")
|
|
103
|
+
current = item.text ? append(null, item.text) : null
|
|
104
|
+
}
|
|
105
|
+
return current
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/**
|
|
109
|
+
* Fold whole lines of a session's stdout into the draft, starting from `draft` (the value left
|
|
110
|
+
* by the previous read). Lines that say nothing about the text in flight leave it as it was.
|
|
111
|
+
*
|
|
112
|
+
* - a text block starting opens a fresh draft — one draft is one block, as one stored `message`
|
|
113
|
+
* is one block;
|
|
114
|
+
* - a `text_delta` grows it;
|
|
115
|
+
* - the finished `assistant` message, or the turn's `result`, closes it: from there on the
|
|
116
|
+
* timeline has the real thing.
|
|
117
|
+
*
|
|
118
|
+
* A subagent's partials (`parent_tool_use_id` set) are skipped: what it writes is not what the
|
|
119
|
+
* session says to the reader, and interleaving it would garble the draft of the main thread.
|
|
120
|
+
*/
|
|
121
|
+
export function foldDraft(protocol: AgentProtocol, lines: string[], draft: AgentDraft | null): AgentDraft | null {
|
|
122
|
+
if (!streamsPartials(protocol)) return null
|
|
123
|
+
if (protocol === "agent-v1") return foldContract(lines, draft)
|
|
124
|
+
if (protocol === "codex-runner") return foldCodex(lines, draft)
|
|
125
|
+
let current = draft
|
|
126
|
+
for (const line of lines) {
|
|
127
|
+
const trimmed = line.trim()
|
|
128
|
+
// Cheap pre-filter: most lines are not about the draft, and some carry megabytes.
|
|
129
|
+
if (!trimmed.startsWith("{")) continue
|
|
130
|
+
if (!trimmed.includes('"stream_event"') && !trimmed.includes('"assistant"') && !trimmed.includes('"result"')) continue
|
|
131
|
+
let obj: Record<string, unknown>
|
|
132
|
+
try {
|
|
133
|
+
const parsed = JSON.parse(trimmed)
|
|
134
|
+
if (!parsed || typeof parsed !== "object" || Array.isArray(parsed)) continue
|
|
135
|
+
obj = parsed as Record<string, unknown>
|
|
136
|
+
} catch {
|
|
137
|
+
continue
|
|
138
|
+
}
|
|
139
|
+
if (obj.parent_tool_use_id) continue
|
|
140
|
+
|
|
141
|
+
if (obj.type === "assistant" || obj.type === "result") {
|
|
142
|
+
current = null
|
|
143
|
+
continue
|
|
144
|
+
}
|
|
145
|
+
if (obj.type !== "stream_event") continue
|
|
146
|
+
const event = (obj.event ?? {}) as Record<string, unknown>
|
|
147
|
+
if (event.type === "content_block_start") {
|
|
148
|
+
const block = (event.content_block ?? {}) as Record<string, unknown>
|
|
149
|
+
if (block.type === "text") current = append(null, typeof block.text === "string" ? block.text : "")
|
|
150
|
+
} else if (event.type === "content_block_delta") {
|
|
151
|
+
const delta = (event.delta ?? {}) as Record<string, unknown>
|
|
152
|
+
if (delta.type === "text_delta" && typeof delta.text === "string") current = append(current, delta.text)
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
return current
|
|
156
|
+
}
|
|
@@ -33,6 +33,9 @@ export {
|
|
|
33
33
|
TASK_EVENT_TYPES,
|
|
34
34
|
userTurnLine,
|
|
35
35
|
} from "./agent-stream"
|
|
36
|
+
// The text block a streaming harness is still writing — folded from its partial messages, never stored.
|
|
37
|
+
export { foldDraft } from "./draft"
|
|
38
|
+
export type { AgentDraft } from "./draft"
|
|
36
39
|
// Turns that ended on a usage/rate limit rather than on finished work, and when the limit lifts.
|
|
37
40
|
export { claudeTurnLimit, resetFromText, TURN_LIMIT_KINDS } from "./turn-limit"
|
|
38
41
|
export type { TurnLimit, TurnLimitKind } from "./turn-limit"
|