@jigging/agent-acp 0.0.0 → 0.1.0-alpha.3
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/AGENTS.md +88 -0
- package/FLOW.contract.json +241 -0
- package/FLOW.meta.json +6 -0
- package/FLOW.ts +3 -0
- package/LICENSE +374 -0
- package/README.md +156 -0
- package/THIRD_PARTY_NOTICES +14 -0
- package/contracts/acp-public-updates.json +75 -0
- package/contracts/agent-commands.json +73 -0
- package/contracts/agent-replies.json +156 -0
- package/contracts/finite-acp/contract.json +104 -0
- package/contracts/finite-acp/requests.json +24 -0
- package/contracts/finite-acp/responses.json +102 -0
- package/dist/conversation.d.ts +8 -0
- package/dist/conversation.js +155 -0
- package/dist/flow.d.ts +4 -0
- package/dist/flow.js +3393 -0
- package/dist/transport.d.ts +56 -0
- package/dist/transport.js +209 -0
- package/dist/updates.d.ts +16 -0
- package/dist/updates.js +77 -0
- package/justfile +32 -0
- package/licenses/agent-method.LICENSE +373 -0
- package/licenses/flow.LICENSE +202 -0
- package/package.json +34 -4
- package/src/conversation.ts +192 -0
- package/src/flow.ts +495 -0
- package/src/transport.ts +242 -0
- package/src/updates.ts +76 -0
- package/test/flow.test.ts +982 -0
- package/test/pack.test.ts +76 -0
- package/test/transport.test.ts +135 -0
- package/test/updates.test.ts +94 -0
- package/tsconfig.json +17 -0
package/src/transport.ts
ADDED
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
/** Framing is data transport, not permission to dispatch an ACP operation. */
|
|
2
|
+
export const FINITE_ACP_LIMITS = Object.freeze({
|
|
3
|
+
fragmentBytes: 8_192,
|
|
4
|
+
frameBytes: 16_777_216,
|
|
5
|
+
requestBytes: 8_388_608,
|
|
6
|
+
responseBytes: 33_554_432,
|
|
7
|
+
requestFrames: 64,
|
|
8
|
+
responseFrames: 8_192,
|
|
9
|
+
fragments: 16_384,
|
|
10
|
+
identifierBytes: 1_024,
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
export type FiniteAcpConfiguration =
|
|
14
|
+
| { readonly configId: string; readonly value: string }
|
|
15
|
+
| { readonly configId: string; readonly type: 'boolean'; readonly value: boolean }
|
|
16
|
+
|
|
17
|
+
export interface FiniteAcpReady {
|
|
18
|
+
readonly kind: 'ready'
|
|
19
|
+
readonly protocolVersion: 1
|
|
20
|
+
readonly cwd: '/work'
|
|
21
|
+
readonly maxTurns: number
|
|
22
|
+
readonly configuration: readonly FiniteAcpConfiguration[]
|
|
23
|
+
readonly modeId?: string
|
|
24
|
+
readonly restoreSessionId?: string
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
export interface FiniteAcpFragment {
|
|
28
|
+
readonly kind: 'data'
|
|
29
|
+
readonly text: string
|
|
30
|
+
readonly end: boolean
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export class FiniteAcpTransportError extends Error {
|
|
34
|
+
constructor(message: string) {
|
|
35
|
+
super(message)
|
|
36
|
+
this.name = 'FiniteAcpTransportError'
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
const encoder = new TextEncoder()
|
|
41
|
+
|
|
42
|
+
/** Does not invoke getters or accept non-data objects at a library boundary. */
|
|
43
|
+
function object(value: unknown): Record<string, unknown> {
|
|
44
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value))
|
|
45
|
+
invalid('Expected an object')
|
|
46
|
+
const prototype = Object.getPrototypeOf(value)
|
|
47
|
+
if (prototype !== null && prototype !== Object.prototype) invalid('Expected a data object')
|
|
48
|
+
const result: Record<string, unknown> = Object.create(null)
|
|
49
|
+
for (const key of Reflect.ownKeys(value)) {
|
|
50
|
+
if (typeof key !== 'string') invalid('Unexpected object key')
|
|
51
|
+
const descriptor = Object.getOwnPropertyDescriptor(value, key)
|
|
52
|
+
if (!descriptor || !descriptor.enumerable || !Object.hasOwn(descriptor, 'value'))
|
|
53
|
+
invalid('Expected data properties')
|
|
54
|
+
result[key] = descriptor.value
|
|
55
|
+
}
|
|
56
|
+
return result
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
function exact(
|
|
60
|
+
value: Record<string, unknown>,
|
|
61
|
+
required: readonly string[],
|
|
62
|
+
optional: readonly string[] = [],
|
|
63
|
+
): void {
|
|
64
|
+
if (
|
|
65
|
+
required.some((key) => !Object.hasOwn(value, key)) ||
|
|
66
|
+
Object.keys(value).some((key) => !required.includes(key) && !optional.includes(key))
|
|
67
|
+
)
|
|
68
|
+
invalid('Unexpected object fields')
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
/** Count scalar UTF-8 bytes without replacing invalid Unicode or allocating it. */
|
|
72
|
+
function textBytes(value: unknown, maximum: number, empty = false): number {
|
|
73
|
+
if (typeof value !== 'string' || (!empty && value.length === 0)) invalid('Expected nonempty text')
|
|
74
|
+
let bytes = 0
|
|
75
|
+
for (let index = 0; index < value.length; index++) {
|
|
76
|
+
const unit = value.charCodeAt(index)
|
|
77
|
+
if (unit >= 0xd800 && unit <= 0xdbff) {
|
|
78
|
+
const next = value.charCodeAt(++index)
|
|
79
|
+
if (!(next >= 0xdc00 && next <= 0xdfff)) invalid('Text is not Unicode scalar data')
|
|
80
|
+
bytes += 4
|
|
81
|
+
} else if (unit >= 0xdc00 && unit <= 0xdfff) invalid('Text is not Unicode scalar data')
|
|
82
|
+
else bytes += unit < 0x80 ? 1 : unit < 0x800 ? 2 : 3
|
|
83
|
+
if (bytes > maximum) invalid('Text exceeds the finite ACP byte limit')
|
|
84
|
+
}
|
|
85
|
+
return bytes
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function identifier(value: unknown): string {
|
|
89
|
+
textBytes(value, FINITE_ACP_LIMITS.identifierBytes)
|
|
90
|
+
if ((value as string).includes('\0')) invalid('Identifier contains NUL')
|
|
91
|
+
return value as string
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Snapshot the host's non-secret ready record; this copy grants no authority. */
|
|
95
|
+
export function readFiniteAcpReady(value: unknown): FiniteAcpReady {
|
|
96
|
+
const record = object(value)
|
|
97
|
+
exact(
|
|
98
|
+
record,
|
|
99
|
+
['kind', 'protocolVersion', 'cwd', 'maxTurns', 'configuration'],
|
|
100
|
+
['modeId', 'restoreSessionId'],
|
|
101
|
+
)
|
|
102
|
+
if (
|
|
103
|
+
record.kind !== 'ready' ||
|
|
104
|
+
record.protocolVersion !== 1 ||
|
|
105
|
+
record.cwd !== '/work' ||
|
|
106
|
+
typeof record.maxTurns !== 'number' ||
|
|
107
|
+
!Number.isSafeInteger(record.maxTurns) ||
|
|
108
|
+
record.maxTurns < 1 ||
|
|
109
|
+
record.maxTurns > 8 ||
|
|
110
|
+
!Array.isArray(record.configuration) ||
|
|
111
|
+
record.configuration.length > 16
|
|
112
|
+
)
|
|
113
|
+
invalid('Invalid finite ACP ready record')
|
|
114
|
+
const names = new Set<string>()
|
|
115
|
+
const input = record.configuration
|
|
116
|
+
if (
|
|
117
|
+
Object.getPrototypeOf(input) !== Array.prototype ||
|
|
118
|
+
Reflect.ownKeys(input).length !== input.length + 1
|
|
119
|
+
)
|
|
120
|
+
invalid('Configuration must be a dense data array')
|
|
121
|
+
const configuration = Array.from({ length: input.length }, (_, index) => {
|
|
122
|
+
const descriptor = Object.getOwnPropertyDescriptor(input, String(index))
|
|
123
|
+
if (!descriptor || !Object.hasOwn(descriptor, 'value') || !descriptor.enumerable)
|
|
124
|
+
invalid('Configuration must be a dense data array')
|
|
125
|
+
const item = descriptor.value
|
|
126
|
+
const entry = object(item)
|
|
127
|
+
exact(entry, ['configId', 'value'], ['type'])
|
|
128
|
+
const configId = identifier(entry.configId)
|
|
129
|
+
if (names.has(configId)) invalid('Duplicate finite ACP configuration')
|
|
130
|
+
names.add(configId)
|
|
131
|
+
if (entry.type === 'boolean') {
|
|
132
|
+
if (typeof entry.value !== 'boolean') invalid('Invalid Boolean configuration')
|
|
133
|
+
return Object.freeze({ configId, type: 'boolean' as const, value: entry.value })
|
|
134
|
+
}
|
|
135
|
+
if (Object.hasOwn(entry, 'type')) invalid('Unsupported finite ACP configuration')
|
|
136
|
+
return Object.freeze({ configId, value: identifier(entry.value) })
|
|
137
|
+
})
|
|
138
|
+
const ready: FiniteAcpReady = Object.freeze({
|
|
139
|
+
kind: 'ready',
|
|
140
|
+
protocolVersion: 1,
|
|
141
|
+
cwd: '/work',
|
|
142
|
+
maxTurns: record.maxTurns as number,
|
|
143
|
+
configuration: Object.freeze(configuration),
|
|
144
|
+
...(Object.hasOwn(record, 'modeId') ? { modeId: identifier(record.modeId) } : {}),
|
|
145
|
+
...(Object.hasOwn(record, 'restoreSessionId')
|
|
146
|
+
? { restoreSessionId: identifier(record.restoreSessionId) }
|
|
147
|
+
: {}),
|
|
148
|
+
})
|
|
149
|
+
if (encoder.encode(JSON.stringify(ready)).byteLength > 65_536)
|
|
150
|
+
invalid('Ready record exceeds the channel item bound')
|
|
151
|
+
return ready
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
/** One complete JSON frame, split only between Unicode scalars. No replay IDs. */
|
|
155
|
+
export function* fragmentFiniteAcpFrame(text: string): Generator<FiniteAcpFragment> {
|
|
156
|
+
textBytes(text, FINITE_ACP_LIMITS.frameBytes)
|
|
157
|
+
let start = 0
|
|
158
|
+
let bytes = 0
|
|
159
|
+
for (let index = 0; index < text.length; ) {
|
|
160
|
+
const code = text.codePointAt(index)!
|
|
161
|
+
const width = code > 0xffff ? 2 : 1
|
|
162
|
+
const size = code < 0x80 ? 1 : code < 0x800 ? 2 : code < 0x10000 ? 3 : 4
|
|
163
|
+
if (bytes + size > FINITE_ACP_LIMITS.fragmentBytes) {
|
|
164
|
+
yield Object.freeze({ kind: 'data', text: text.slice(start, index), end: false })
|
|
165
|
+
start = index
|
|
166
|
+
bytes = 0
|
|
167
|
+
}
|
|
168
|
+
bytes += size
|
|
169
|
+
index += width
|
|
170
|
+
}
|
|
171
|
+
yield Object.freeze({ kind: 'data', text: text.slice(start), end: true })
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** Bounded single-frame assembly. Failure permanently invalidates this stream. */
|
|
175
|
+
export class FiniteAcpFrames {
|
|
176
|
+
private readonly maximumBytes: number
|
|
177
|
+
private readonly maximumFrames: number
|
|
178
|
+
private totalBytes = 0
|
|
179
|
+
private frameBytes = 0
|
|
180
|
+
private frames = 0
|
|
181
|
+
private fragments = 0
|
|
182
|
+
private parts: string[] = []
|
|
183
|
+
private ended = false
|
|
184
|
+
private failed = false
|
|
185
|
+
|
|
186
|
+
constructor(direction: 'requests' | 'responses') {
|
|
187
|
+
if (direction !== 'requests' && direction !== 'responses')
|
|
188
|
+
invalid('Unknown finite ACP direction')
|
|
189
|
+
this.maximumBytes =
|
|
190
|
+
direction === 'requests' ? FINITE_ACP_LIMITS.requestBytes : FINITE_ACP_LIMITS.responseBytes
|
|
191
|
+
this.maximumFrames =
|
|
192
|
+
direction === 'requests' ? FINITE_ACP_LIMITS.requestFrames : FINITE_ACP_LIMITS.responseFrames
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
accept(value: unknown): string | undefined {
|
|
196
|
+
return this.guard(() => {
|
|
197
|
+
const record = object(value)
|
|
198
|
+
exact(record, ['kind', 'text', 'end'])
|
|
199
|
+
if (record.kind !== 'data' || typeof record.end !== 'boolean')
|
|
200
|
+
invalid('Invalid finite ACP fragment')
|
|
201
|
+
const bytes = textBytes(record.text, FINITE_ACP_LIMITS.fragmentBytes)
|
|
202
|
+
this.frameBytes += bytes
|
|
203
|
+
this.totalBytes += bytes
|
|
204
|
+
this.fragments++
|
|
205
|
+
if (
|
|
206
|
+
this.frameBytes > FINITE_ACP_LIMITS.frameBytes ||
|
|
207
|
+
this.totalBytes > this.maximumBytes ||
|
|
208
|
+
this.fragments > FINITE_ACP_LIMITS.fragments
|
|
209
|
+
)
|
|
210
|
+
invalid('Finite ACP stream exceeds its bounds')
|
|
211
|
+
this.parts.push(record.text as string)
|
|
212
|
+
if (!record.end) return undefined
|
|
213
|
+
if (++this.frames > this.maximumFrames) invalid('Finite ACP stream has too many frames')
|
|
214
|
+
const frame = this.parts.join('')
|
|
215
|
+
this.parts = []
|
|
216
|
+
this.frameBytes = 0
|
|
217
|
+
return frame
|
|
218
|
+
})
|
|
219
|
+
}
|
|
220
|
+
|
|
221
|
+
finish(): void {
|
|
222
|
+
this.guard(() => {
|
|
223
|
+
if (this.parts.length !== 0) invalid('Finite ACP stream ended inside a frame')
|
|
224
|
+
this.ended = true
|
|
225
|
+
})
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
private guard<T>(action: () => T): T {
|
|
229
|
+
if (this.failed || this.ended) invalid('Finite ACP stream is not open')
|
|
230
|
+
try {
|
|
231
|
+
return action()
|
|
232
|
+
} catch (error) {
|
|
233
|
+
this.failed = true
|
|
234
|
+
this.parts = []
|
|
235
|
+
throw error
|
|
236
|
+
}
|
|
237
|
+
}
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
function invalid(message: string): never {
|
|
241
|
+
throw new FiniteAcpTransportError(message)
|
|
242
|
+
}
|
package/src/updates.ts
ADDED
|
@@ -0,0 +1,76 @@
|
|
|
1
|
+
import type { ChannelSender, JsonValue } from '@jigging/flow'
|
|
2
|
+
|
|
3
|
+
const encoder = new TextEncoder()
|
|
4
|
+
const MAX_ITEMS = 16
|
|
5
|
+
const MAX_BYTES = 262_144
|
|
6
|
+
const SEND_WAIT_MS = 500
|
|
7
|
+
|
|
8
|
+
/** Optional presentation never backpressures the essential ACP conversation. */
|
|
9
|
+
export class OptionalUpdates {
|
|
10
|
+
private readonly stopped = new AbortController()
|
|
11
|
+
private readonly queue: { value: JsonValue; bytes: number }[] = []
|
|
12
|
+
private bytes = 0
|
|
13
|
+
private pumping: Promise<void> | undefined
|
|
14
|
+
private incomplete = false
|
|
15
|
+
|
|
16
|
+
constructor(
|
|
17
|
+
private readonly sender: ChannelSender | undefined,
|
|
18
|
+
private readonly signal: AbortSignal,
|
|
19
|
+
) {}
|
|
20
|
+
|
|
21
|
+
offer(value: JsonValue): void {
|
|
22
|
+
if (!this.sender || this.incomplete || this.signal.aborted) return
|
|
23
|
+
const bytes = encoder.encode(JSON.stringify(value)).byteLength
|
|
24
|
+
if (bytes > 65_536 || this.queue.length >= MAX_ITEMS || this.bytes + bytes > MAX_BYTES) {
|
|
25
|
+
this.stop()
|
|
26
|
+
return
|
|
27
|
+
}
|
|
28
|
+
this.queue.push({ value, bytes })
|
|
29
|
+
this.bytes += bytes
|
|
30
|
+
this.pumping ??= this.pump()
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
async finish(): Promise<void> {
|
|
34
|
+
await this.pumping
|
|
35
|
+
if (!this.sender) return
|
|
36
|
+
try {
|
|
37
|
+
await this.sender.close(this.incomplete ? { error: 'LAGGED' } : undefined)
|
|
38
|
+
} catch {
|
|
39
|
+
this.stop()
|
|
40
|
+
}
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
private async pump(): Promise<void> {
|
|
44
|
+
try {
|
|
45
|
+
while (this.queue.length && !this.incomplete) {
|
|
46
|
+
const item = this.queue[0]!
|
|
47
|
+
const timer = new AbortController()
|
|
48
|
+
const timeout = setTimeout(() => timer.abort(), SEND_WAIT_MS)
|
|
49
|
+
try {
|
|
50
|
+
await this.sender!.send(item.value, {
|
|
51
|
+
signal: AbortSignal.any([this.signal, this.stopped.signal, timer.signal]),
|
|
52
|
+
})
|
|
53
|
+
} finally {
|
|
54
|
+
clearTimeout(timeout)
|
|
55
|
+
}
|
|
56
|
+
if (!this.incomplete) {
|
|
57
|
+
this.queue.shift()
|
|
58
|
+
this.bytes -= item.bytes
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
} catch {
|
|
62
|
+
this.stop()
|
|
63
|
+
} finally {
|
|
64
|
+
this.pumping = undefined
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
private stop(): void {
|
|
69
|
+
if (this.incomplete) return
|
|
70
|
+
this.incomplete = true
|
|
71
|
+
this.queue.length = 0
|
|
72
|
+
this.bytes = 0
|
|
73
|
+
this.stopped.abort()
|
|
74
|
+
console.error('Agent progress is incomplete; the execution result remains separate.')
|
|
75
|
+
}
|
|
76
|
+
}
|