@jigging/agent-method 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.
Files changed (52) hide show
  1. package/AGENTS.md +98 -0
  2. package/FLOW.contract.json +241 -0
  3. package/FLOW.meta.json +8 -0
  4. package/FLOW.ts +3 -0
  5. package/LICENSE +373 -0
  6. package/README.md +369 -0
  7. package/THIRD_PARTY_NOTICES +9 -0
  8. package/contracts/acp-public-updates.json +75 -0
  9. package/contracts/agent-commands.json +88 -0
  10. package/contracts/agent-replies.json +202 -0
  11. package/contracts/http-request/contract.json +37 -0
  12. package/dist/api.d.ts +11 -0
  13. package/dist/api.js +164 -0
  14. package/dist/conversation.d.ts +68 -0
  15. package/dist/conversation.js +346 -0
  16. package/dist/errors.d.ts +5 -0
  17. package/dist/errors.js +8 -0
  18. package/dist/flow.d.ts +3 -0
  19. package/dist/flow.js +2784 -0
  20. package/dist/index.d.ts +67 -0
  21. package/dist/index.js +220 -0
  22. package/dist/json.d.ts +21 -0
  23. package/dist/json.js +409 -0
  24. package/dist/schema.d.ts +7 -0
  25. package/dist/schema.js +180 -0
  26. package/dist/skills.d.ts +3 -0
  27. package/dist/skills.js +132 -0
  28. package/dist/values.d.ts +11 -0
  29. package/dist/values.js +65 -0
  30. package/justfile +32 -0
  31. package/licenses/flow.LICENSE +202 -0
  32. package/package.json +44 -4
  33. package/settings.schema.json +12 -0
  34. package/skills/answer-check/SKILL.md +5 -0
  35. package/src/api.ts +191 -0
  36. package/src/conversation.ts +387 -0
  37. package/src/errors.ts +11 -0
  38. package/src/flow.ts +66 -0
  39. package/src/index.ts +325 -0
  40. package/src/json.ts +406 -0
  41. package/src/schema.ts +230 -0
  42. package/src/skills.ts +138 -0
  43. package/src/values.ts +77 -0
  44. package/test/api.test.ts +326 -0
  45. package/test/conversation-fixture.ts +70 -0
  46. package/test/conversation.test.ts +305 -0
  47. package/test/json.test.ts +88 -0
  48. package/test/method.test.ts +308 -0
  49. package/test/pack.test.ts +81 -0
  50. package/test/result.test.ts +103 -0
  51. package/test/skills-flow.test.ts +252 -0
  52. package/tsconfig.json +17 -0
package/src/api.ts ADDED
@@ -0,0 +1,191 @@
1
+ import { AgentMethodError } from './errors.js'
2
+ import type { AgentTransportResult, PreparedAgent } from './index.js'
3
+ import { canonicalJson, type JsonObject } from './json.js'
4
+ import { projectResponseSchema } from './schema.js'
5
+ import { ordinaryRecord, snapshot } from './values.js'
6
+
7
+ type Api = 'chat-completions' | 'responses'
8
+
9
+ /** One finite text request. API settings choose syntax, never endpoint authority. */
10
+ export function prepareApiRequest(
11
+ prepared: PreparedAgent,
12
+ settings: unknown,
13
+ ): { readonly api: Api; readonly body: JsonObject } {
14
+ const value = ordinaryRecord(snapshot(settings, 'INVALID_INPUT'))
15
+ if (
16
+ value === undefined ||
17
+ Object.keys(value).some(
18
+ (key) => !['model', 'maxCompletionTokens', 'api', 'structuredOutput'].includes(key),
19
+ ) ||
20
+ typeof value.model !== 'string' ||
21
+ value.model.trim().length === 0 ||
22
+ value.model.length > 256
23
+ ) {
24
+ throw new AgentMethodError('INVALID_INPUT', 'Configure the Agent model in Binding settings')
25
+ }
26
+ const tokens = Object.hasOwn(value, 'maxCompletionTokens') ? value.maxCompletionTokens : 4096
27
+ if (!Number.isSafeInteger(tokens) || typeof tokens !== 'number' || tokens < 1 || tokens > 65536)
28
+ throw new AgentMethodError('INVALID_INPUT', 'maxCompletionTokens must be between 1 and 65536')
29
+ const api = Object.hasOwn(value, 'api') ? value.api : 'chat-completions'
30
+ if (api !== 'chat-completions' && api !== 'responses')
31
+ throw new AgentMethodError('INVALID_INPUT', 'api must be chat-completions or responses')
32
+ const structuredOutput = Object.hasOwn(value, 'structuredOutput')
33
+ ? value.structuredOutput
34
+ : 'prompt'
35
+ if (structuredOutput !== 'prompt' && structuredOutput !== 'json-schema')
36
+ throw new AgentMethodError('INVALID_INPUT', 'structuredOutput must be prompt or json-schema')
37
+ const format =
38
+ structuredOutput === 'json-schema' && prepared.request.responseSchema !== undefined
39
+ ? {
40
+ type: 'json_schema',
41
+ name: 'flow_agent_result',
42
+ schema: projectResponseSchema(prepared.request.responseSchema),
43
+ strict: true,
44
+ }
45
+ : undefined
46
+ const body: JsonObject =
47
+ api === 'responses'
48
+ ? {
49
+ model: value.model,
50
+ input: prepared.request.prompt,
51
+ max_output_tokens: tokens,
52
+ stream: false,
53
+ store: false,
54
+ ...(format === undefined ? {} : { text: { format } }),
55
+ }
56
+ : {
57
+ model: value.model,
58
+ messages: [{ role: 'user', content: prepared.request.prompt }],
59
+ max_completion_tokens: tokens,
60
+ n: 1,
61
+ stream: false,
62
+ store: false,
63
+ ...(format === undefined
64
+ ? {}
65
+ : {
66
+ response_format: {
67
+ type: format.type,
68
+ json_schema: { name: format.name, schema: format.schema, strict: format.strict },
69
+ },
70
+ }),
71
+ }
72
+ if (canonicalJson(body).byteLength > 8_388_608)
73
+ throw new AgentMethodError(
74
+ 'RESOURCE_EXHAUSTED',
75
+ 'Rendered request exceeds the 8 MiB HTTP body ceiling',
76
+ )
77
+ return { api, body }
78
+ }
79
+
80
+ /** Interpret only complete HTTP evidence. Never echo an arbitrary provider error body. */
81
+ export function parseApiResult(result: unknown, api: Api): AgentTransportResult {
82
+ const record = ordinaryRecord(snapshot(result, 'INVALID_RESULT'))
83
+ const http = record === undefined ? undefined : ordinaryRecord(record.output)
84
+ if (
85
+ record?.outcome !== 'done' ||
86
+ http === undefined ||
87
+ typeof http.status !== 'number' ||
88
+ !Number.isInteger(http.status) ||
89
+ http.status < 200 ||
90
+ http.status > 599 ||
91
+ !Object.hasOwn(http, 'body')
92
+ )
93
+ return invalid('HTTP slot returned invalid response evidence')
94
+ if (http.status !== 200)
95
+ return invalid(`Agent endpoint returned HTTP ${http.status}; no automatic retry was attempted`)
96
+ if (canonicalJson(http.body as JsonObject).byteLength > 12_582_912)
97
+ throw new AgentMethodError('RESOURCE_EXHAUSTED', 'Agent HTTP response exceeds 12 MiB')
98
+ const body = ordinaryRecord(http.body)
99
+ if (api === 'responses') return responsesResult(body)
100
+ return chatResult(body)
101
+ }
102
+
103
+ function chatResult(body: Record<string, unknown> | undefined): AgentTransportResult {
104
+ const choices = body?.choices
105
+ if (body?.object !== 'chat.completion' || !Array.isArray(choices) || choices.length !== 1)
106
+ return invalid('Expected one complete Chat Completions choice')
107
+ const choice = ordinaryRecord(choices[0])
108
+ const message = ordinaryRecord(choice?.message)
109
+ if (
110
+ choice?.index !== 0 ||
111
+ message?.role !== 'assistant' ||
112
+ (message.tool_calls !== undefined &&
113
+ message.tool_calls !== null &&
114
+ !(Array.isArray(message.tool_calls) && message.tool_calls.length === 0)) ||
115
+ (message.function_call !== undefined && message.function_call !== null)
116
+ )
117
+ return invalid('Agent returned an unsupported message or tool request')
118
+ const content = message.content
119
+ const refusal = message.refusal
120
+ if (
121
+ (content !== null && typeof content !== 'string') ||
122
+ (refusal !== undefined && refusal !== null && typeof refusal !== 'string')
123
+ )
124
+ return invalid('Agent response must contain text or an explicit refusal')
125
+ const reason = choice.finish_reason
126
+ if (reason !== 'stop' && reason !== 'length' && reason !== 'content_filter')
127
+ return invalid('Agent response has no supported terminal stop reason')
128
+ const refused = reason === 'content_filter' || (typeof refusal === 'string' && refusal.length > 0)
129
+ if (content === null && !refused && reason !== 'length')
130
+ return invalid('Completed Agent response omitted text')
131
+ return {
132
+ outcome: 'done',
133
+ output: {
134
+ text: refused ? refusal || content || 'The Agent response was filtered.' : (content ?? ''),
135
+ stop: refused ? 'refusal' : reason === 'length' ? 'limit' : 'end-turn',
136
+ },
137
+ }
138
+ }
139
+
140
+ function responsesResult(body: Record<string, unknown> | undefined): AgentTransportResult {
141
+ if (
142
+ body?.object !== 'response' ||
143
+ (body.status !== 'completed' && body.status !== 'incomplete') ||
144
+ (body.error !== undefined && body.error !== null) ||
145
+ !Array.isArray(body.output)
146
+ )
147
+ return invalid('Agent endpoint returned no completed or limited Responses result')
148
+ const reason = ordinaryRecord(body.incomplete_details)?.reason
149
+ if (body.status === 'incomplete' && reason !== 'max_output_tokens' && reason !== 'content_filter')
150
+ return invalid('Agent response has no supported incomplete reason')
151
+ if (body.status === 'completed' && body.incomplete_details != null)
152
+ return invalid('Completed Agent response includes incomplete details')
153
+
154
+ const texts: string[] = []
155
+ const refusals: string[] = []
156
+ for (const raw of body.output) {
157
+ const item = ordinaryRecord(raw)
158
+ if (item?.type === 'reasoning') continue
159
+ if (
160
+ item?.type !== 'message' ||
161
+ item.role !== 'assistant' ||
162
+ !Array.isArray(item.content) ||
163
+ (item.status !== 'completed' && item.status !== 'incomplete') ||
164
+ (body.status === 'completed' && item.status !== 'completed')
165
+ )
166
+ return invalid('Agent returned an unsupported Responses output or tool request')
167
+ for (const rawPart of item.content) {
168
+ const part = ordinaryRecord(rawPart)
169
+ if (part?.type === 'output_text' && typeof part.text === 'string') texts.push(part.text)
170
+ else if (part?.type === 'refusal' && typeof part.refusal === 'string')
171
+ refusals.push(part.refusal)
172
+ else return invalid('Agent response must contain text or an explicit refusal')
173
+ }
174
+ }
175
+ if (body.status === 'completed' && texts.length === 0 && refusals.length === 0)
176
+ return invalid('Completed Agent response omitted text')
177
+ const refused = refusals.length > 0 || reason === 'content_filter'
178
+ return {
179
+ outcome: 'done',
180
+ output: {
181
+ text: refused
182
+ ? refusals.join('\n') || texts.join('') || 'The Agent response was filtered.'
183
+ : texts.join(''),
184
+ stop: refused ? 'refusal' : body.status === 'incomplete' ? 'limit' : 'end-turn',
185
+ },
186
+ }
187
+ }
188
+
189
+ function invalid(message: string): never {
190
+ throw new AgentMethodError('INVALID_RESULT', message)
191
+ }
@@ -0,0 +1,387 @@
1
+ import type { ChannelPair, ChannelSender, JsonValue, RunContext, RunResult } from '@jigging/flow'
2
+ import type { AgentCallInput, AgentInput } from './index.js'
3
+ import { exactKeys, ordinaryRecord, validSessionReceipt } from './values.js'
4
+
5
+ export type AgentTurn =
6
+ | { readonly type: 'result'; readonly turn: number; readonly result: RunResult }
7
+ | { readonly type: 'cancelled'; readonly turn: number }
8
+ | {
9
+ readonly type: 'error'
10
+ readonly turn: number
11
+ readonly code: 'INVALID_RESULT'
12
+ readonly message: string
13
+ }
14
+
15
+ export interface AgentConversation {
16
+ readonly initial: Promise<AgentTurn>
17
+ prompt(input: Omit<AgentInput, 'session'>): Promise<AgentTurn>
18
+ /** Acknowledges control, not cancellation. Await the turn for its actual outcome. */
19
+ interrupt(): Promise<'accepted' | 'not-running'>
20
+ }
21
+
22
+ export interface ConversationOptions {
23
+ readonly operationId: string
24
+ readonly slot: string
25
+ readonly input: AgentCallInput
26
+ /** Optional caller-created public update writer; observation stays application-owned. */
27
+ readonly events?: ChannelSender
28
+ /** Synchronous filtering/presentation of public updates; no channel setup required. */
29
+ readonly onEvent?: (event: AgentUpdate) => void
30
+ }
31
+
32
+ export type AgentUpdate =
33
+ | {
34
+ readonly sessionUpdate: 'agent_message_chunk'
35
+ readonly turn?: number
36
+ readonly messageId?: string
37
+ readonly content: { readonly type: 'text'; readonly text: string }
38
+ }
39
+ | {
40
+ readonly sessionUpdate: 'plan'
41
+ readonly turn?: number
42
+ readonly entries: readonly {
43
+ readonly content: string
44
+ readonly priority: 'high' | 'medium' | 'low'
45
+ readonly status: 'pending' | 'in_progress' | 'completed'
46
+ }[]
47
+ }
48
+
49
+ export type AgentObservation =
50
+ | { readonly status: 'complete' }
51
+ | { readonly status: 'incomplete'; readonly errors: readonly unknown[] }
52
+
53
+ export interface ConversationResult<T> {
54
+ readonly value: T
55
+ readonly turns: readonly AgentTurn[]
56
+ readonly settlement: RunResult
57
+ /** Present only with onEvent. Observation never stands in for execution settlement. */
58
+ readonly observation?: AgentObservation
59
+ }
60
+
61
+ /** Retains received answers and every primary/cleanup failure; never manufactures success. */
62
+ export class AgentConversationError extends AggregateError {
63
+ constructor(
64
+ errors: readonly unknown[],
65
+ readonly turns: readonly AgentTurn[],
66
+ readonly settlement?: RunResult,
67
+ ) {
68
+ super(errors, 'Agent conversation did not complete cleanly', { cause: errors[0] })
69
+ this.name = 'AgentConversationError'
70
+ }
71
+ }
72
+
73
+ function deferred<T>() {
74
+ let resolve!: (value: T) => void
75
+ let reject!: (error: unknown) => void
76
+ const promise = new Promise<T>((yes, no) => {
77
+ resolve = yes
78
+ reject = no
79
+ })
80
+ // A caller may inspect another part of a turn first. Rejections are still
81
+ // returned to every waiter; this is not a global failure acknowledgement ledger.
82
+ void promise.catch(() => undefined)
83
+ return { promise, resolve, reject }
84
+ }
85
+
86
+ function invalid(message: string): Error {
87
+ return new Error(message)
88
+ }
89
+
90
+ /** Ordinary Agent Run caller logic: no ACP implementation, host imports, or provider access. */
91
+ export async function withAgentConversation<T>(
92
+ run: RunContext,
93
+ options: ConversationOptions,
94
+ use: (conversation: AgentConversation) => Promise<T>,
95
+ ): Promise<ConversationResult<T>> {
96
+ if (options.events && options.onEvent)
97
+ throw new TypeError('Choose onEvent or an external events writer, not both')
98
+ const requestedSession = options.input.session !== undefined
99
+ const turns: AgentTurn[] = []
100
+ const errors: unknown[] = []
101
+ const record = (error: unknown) => {
102
+ if (!errors.includes(error)) errors.push(error)
103
+ }
104
+ const reads = new AbortController()
105
+ const pending = new Set<ReturnType<typeof deferred<unknown>>>()
106
+ const tracked = <V>() => {
107
+ const value = deferred<V>()
108
+ pending.add(value as ReturnType<typeof deferred<unknown>>)
109
+ void value.promise.then(
110
+ () => pending.delete(value as ReturnType<typeof deferred<unknown>>),
111
+ () => pending.delete(value as ReturnType<typeof deferred<unknown>>),
112
+ )
113
+ return value
114
+ }
115
+ const fail = (error: unknown) => {
116
+ record(error)
117
+ reads.abort(error)
118
+ for (const value of pending) value.reject(error)
119
+ }
120
+ let commands: ChannelPair | undefined
121
+ let replies: ChannelPair | undefined
122
+ let events: ChannelPair | undefined
123
+ let observer: Promise<void> | undefined
124
+ const observerStop = new AbortController()
125
+ const observationErrors: unknown[] = []
126
+ let offered = false
127
+ let work: Promise<{ result: RunResult } | { error: unknown }> | undefined
128
+ let pump: Promise<void> | undefined
129
+ let settlement: RunResult | undefined
130
+ let writerClosed = false
131
+ let value!: T
132
+ let active = true
133
+ let current = { turn: 0, result: tracked<AgentTurn>() }
134
+ let promptControl:
135
+ | { accepted: ReturnType<typeof deferred<void>>; previous: typeof current }
136
+ | undefined
137
+ let interruptControl: ReturnType<typeof deferred<'accepted' | 'not-running'>> | undefined
138
+ let closeControl: ReturnType<typeof deferred<void>> | undefined
139
+ let controls = 0
140
+ let finished = false
141
+ const send = async (message: JsonValue) => {
142
+ if (++controls > 64) throw invalid('Conversation control limit is 64 commands')
143
+ await commands!.send.send(message, { signal: run.signal })
144
+ }
145
+ try {
146
+ commands = await run.channel({
147
+ contract: { slot: options.slot, channel: 'commands' },
148
+ })
149
+ replies = await run.channel({
150
+ contract: { slot: options.slot, channel: 'replies' },
151
+ })
152
+ if (options.onEvent) {
153
+ events = await run.channel({
154
+ contract: { slot: options.slot, channel: 'events' },
155
+ })
156
+ }
157
+ offered = true
158
+ // Do not cancel this local waiter to stop a conversation. Its real terminal
159
+ // is the evidence that lets a healthy parent subsequently use the capacity.
160
+ work = run
161
+ .call({
162
+ operationId: options.operationId,
163
+ slot: options.slot,
164
+ input: { ...options.input, conversation: true } as unknown as JsonValue,
165
+ channels: {
166
+ commands: commands.receive,
167
+ replies: replies.send,
168
+ ...(events ? { events: events.send } : options.events ? { events: options.events } : {}),
169
+ },
170
+ })
171
+ .then(
172
+ (result) => ({ result }),
173
+ (error) => {
174
+ fail(error)
175
+ observerStop.abort(error)
176
+ return { error }
177
+ },
178
+ )
179
+ if (events) {
180
+ observer = (async () => {
181
+ try {
182
+ for (;;) {
183
+ const item = await events!.receive.next({
184
+ signal: AbortSignal.any([run.signal, observerStop.signal]),
185
+ })
186
+ if (item.done) break
187
+ const returned: unknown = options.onEvent!(item.value as unknown as AgentUpdate)
188
+ if (returned && typeof (returned as PromiseLike<unknown>).then === 'function') {
189
+ void Promise.resolve(returned).catch(() => undefined)
190
+ throw new TypeError(
191
+ 'onEvent must be synchronous; use events for asynchronous channel processing',
192
+ )
193
+ }
194
+ }
195
+ } catch (error) {
196
+ observationErrors.push(error)
197
+ } finally {
198
+ try {
199
+ await events!.receive.close()
200
+ } catch (error) {
201
+ if (!observationErrors.includes(error)) observationErrors.push(error)
202
+ }
203
+ }
204
+ })()
205
+ }
206
+ pump = (async () => {
207
+ try {
208
+ for (;;) {
209
+ const item = await replies!.receive.next({
210
+ signal: AbortSignal.any([run.signal, reads.signal]),
211
+ })
212
+ if (item.done) throw invalid('Agent replies ended before accepted conversation close')
213
+ const message = item.value as Record<string, JsonValue>
214
+ if (
215
+ !message ||
216
+ Array.isArray(message) ||
217
+ typeof message !== 'object' ||
218
+ message.turn !== current.turn
219
+ )
220
+ throw invalid('Agent reply has an unexpected turn')
221
+ if (message.type === 'accepted' || message.type === 'rejected') {
222
+ if (message.command === 'prompt' && promptControl) {
223
+ const control = promptControl
224
+ promptControl = undefined
225
+ if (message.type === 'accepted') control.accepted.resolve()
226
+ else {
227
+ const error = invalid(`Agent rejected prompt: ${String(message.code)}`)
228
+ current.result.reject(error)
229
+ current = control.previous
230
+ active = false
231
+ control.accepted.reject(error)
232
+ }
233
+ } else if (message.command === 'interrupt' && interruptControl) {
234
+ const control = interruptControl
235
+ interruptControl = undefined
236
+ if (message.type === 'accepted') control.resolve('accepted')
237
+ else if (message.code === 'NOT_RUNNING') control.resolve('not-running')
238
+ else control.reject(invalid(`Agent rejected interruption: ${String(message.code)}`))
239
+ } else if (message.command === 'close' && closeControl) {
240
+ if (message.type !== 'accepted') throw invalid('Agent rejected conversation close')
241
+ closeControl.resolve()
242
+ return
243
+ } else throw invalid('Agent sent an unexpected control reply')
244
+ } else {
245
+ if (!active || promptControl) throw invalid('Agent sent an out-of-order turn result')
246
+ if (message.type === 'result') {
247
+ const result = message.result as unknown as RunResult
248
+ if (!result || typeof result.outcome !== 'string' || !Object.hasOwn(result, 'output'))
249
+ throw invalid('Agent omitted its complete turn result')
250
+ } else if (
251
+ message.type !== 'cancelled' &&
252
+ !(
253
+ message.type === 'error' &&
254
+ message.code === 'INVALID_RESULT' &&
255
+ typeof message.message === 'string'
256
+ )
257
+ )
258
+ throw invalid('Agent sent an invalid turn terminal')
259
+ const turn = item.value as unknown as AgentTurn
260
+ if (turns.length >= 8) throw invalid('Agent exceeded the eight-turn conversation limit')
261
+ turns.push(turn)
262
+ active = false
263
+ current.result.resolve(turn)
264
+ }
265
+ }
266
+ } catch (error) {
267
+ if (!reads.signal.aborted) fail(error)
268
+ }
269
+ })()
270
+ const conversation: AgentConversation = {
271
+ initial: current.result.promise,
272
+ prompt(input) {
273
+ if (finished || active || interruptControl || errors.length)
274
+ return Promise.reject(invalid('Conversation is not ready for another prompt'))
275
+ if (current.turn >= 7) return Promise.reject(invalid('Conversation turn limit reached'))
276
+ const previous = current
277
+ current = { turn: previous.turn + 1, result: tracked<AgentTurn>() }
278
+ const result = current.result.promise
279
+ active = true
280
+ promptControl = { accepted: tracked<void>(), previous }
281
+ void send({
282
+ type: 'prompt',
283
+ turn: current.turn,
284
+ input: input as unknown as JsonValue,
285
+ }).catch(fail)
286
+ return result
287
+ },
288
+ async interrupt() {
289
+ if (finished || errors.length) throw invalid('Conversation is closing or failed')
290
+ if (promptControl) await promptControl.accepted.promise
291
+ if (!active) return 'not-running'
292
+ if (interruptControl) return interruptControl.promise
293
+ const control = tracked<'accepted' | 'not-running'>()
294
+ interruptControl = control
295
+ void send({ type: 'interrupt', turn: current.turn }).catch(fail)
296
+ return control.promise
297
+ },
298
+ }
299
+ try {
300
+ value = await use(conversation)
301
+ } catch (error) {
302
+ record(error)
303
+ }
304
+ finished = true
305
+ if (active) record(invalid('Application left a live Agent turn unfinished'))
306
+ if (!active && !reads.signal.aborted && !run.signal.aborted) {
307
+ if (interruptControl) await interruptControl.promise
308
+ closeControl = tracked<void>()
309
+ await send({ type: 'close', turn: current.turn })
310
+ await commands.send.close({ signal: run.signal })
311
+ writerClosed = true
312
+ await closeControl.promise
313
+ }
314
+ } catch (error) {
315
+ record(error)
316
+ } finally {
317
+ finished = true
318
+ // A failed callback/transport stops delivery to the Agent through ordinary
319
+ // channel closure, while preserving the non-cancelled invocation waiter.
320
+ if (commands && !writerClosed) {
321
+ try {
322
+ await commands.send.close({ error: 'LAGGED', signal: run.signal })
323
+ } catch (error) {
324
+ record(error)
325
+ }
326
+ }
327
+ reads.abort()
328
+ await pump
329
+ if (work) {
330
+ const terminal = await work
331
+ if ('error' in terminal) record(terminal.error)
332
+ else settlement = terminal.result
333
+ }
334
+ await observer
335
+ if (events && !offered) {
336
+ try {
337
+ await events.send.close({ error: 'LAGGED', signal: run.signal })
338
+ } catch (error) {
339
+ record(error)
340
+ }
341
+ try {
342
+ await events.receive.close()
343
+ } catch (error) {
344
+ record(error)
345
+ }
346
+ }
347
+ if (replies) {
348
+ try {
349
+ await replies.receive.close()
350
+ } catch (error) {
351
+ record(error)
352
+ }
353
+ }
354
+ // An allocated receiver that was never offered is still ours.
355
+ if (commands && !offered) {
356
+ try {
357
+ await commands.receive.close()
358
+ } catch (error) {
359
+ record(error)
360
+ }
361
+ }
362
+ }
363
+ if (run.signal.aborted) record(run.signal.reason ?? invalid('Run cancelled'))
364
+ const output = ordinaryRecord(settlement?.output)
365
+ if (
366
+ !settlement ||
367
+ settlement.outcome !== 'done' ||
368
+ !output ||
369
+ output.turns !== turns.length ||
370
+ !exactKeys(output, ['turns', ...(requestedSession ? ['session'] : [])]) ||
371
+ (requestedSession && !validSessionReceipt(output.session))
372
+ )
373
+ record(invalid('Agent omitted matching conversation settlement'))
374
+ if (errors.length) throw new AgentConversationError(errors, Object.freeze(turns), settlement)
375
+ return {
376
+ value,
377
+ turns: Object.freeze(turns),
378
+ settlement: settlement!,
379
+ ...(options.onEvent
380
+ ? {
381
+ observation: observationErrors.length
382
+ ? { status: 'incomplete' as const, errors: Object.freeze(observationErrors) }
383
+ : { status: 'complete' as const },
384
+ }
385
+ : {}),
386
+ }
387
+ }
package/src/errors.ts ADDED
@@ -0,0 +1,11 @@
1
+ export type AgentMethodErrorCode = 'INVALID_INPUT' | 'RESOURCE_EXHAUSTED' | 'INVALID_RESULT'
2
+
3
+ export class AgentMethodError extends Error {
4
+ constructor(
5
+ readonly code: AgentMethodErrorCode,
6
+ message: string,
7
+ ) {
8
+ super(message)
9
+ this.name = 'AgentMethodError'
10
+ }
11
+ }
package/src/flow.ts ADDED
@@ -0,0 +1,66 @@
1
+ import { handle, OperationError, type RunContext, type RunResult } from '@jigging/flow'
2
+ import { parseApiResult, prepareApiRequest } from './api.js'
3
+ import {
4
+ type AgentInput,
5
+ AgentMethodError,
6
+ finishAgent,
7
+ prepareAgent,
8
+ type SkillText,
9
+ } from './index.js'
10
+ import { ordinaryRecord, snapshot } from './values.js'
11
+
12
+ export async function agentFlow(run: RunContext): Promise<RunResult> {
13
+ try {
14
+ const input = ordinaryRecord(snapshot(run.input, 'INVALID_INPUT'))
15
+ if (input && Object.hasOwn(input, 'session'))
16
+ throw new OperationError(
17
+ 'UNAVAILABLE',
18
+ 'This HTTP Agent cannot retain or restore native sessions',
19
+ )
20
+ if (input?.conversation === true)
21
+ throw new OperationError(
22
+ 'UNAVAILABLE',
23
+ 'This Agent supports one-shot calls, not continuing conversations',
24
+ )
25
+ if (
26
+ input === undefined ||
27
+ Object.keys(input).some(
28
+ (name) => !['instructions', 'guidance', 'responseSchema', 'skills'].includes(name),
29
+ )
30
+ ) {
31
+ throw new AgentMethodError(
32
+ 'INVALID_INPUT',
33
+ 'Supply instructions and optional explicit guidance, skills or responseSchema',
34
+ )
35
+ }
36
+ if (Object.keys(run.attachments).length > 0 || Object.keys(run.channels).length > 0) {
37
+ throw new OperationError(
38
+ 'UNAVAILABLE',
39
+ 'This text-only Agent method accepts no attachments or channels',
40
+ )
41
+ }
42
+ const { skills, ...methodInput } = input
43
+ const prepared = prepareAgent(
44
+ methodInput as unknown as AgentInput,
45
+ (skills === undefined ? [] : skills) as unknown as readonly SkillText[],
46
+ )
47
+ const { api, body } = prepareApiRequest(prepared, run.settings)
48
+ const result = await run.call(
49
+ {
50
+ operationId: 'completion',
51
+ slot: 'http',
52
+ input: { body, response: 'json' },
53
+ },
54
+ { signal: run.signal },
55
+ )
56
+ const resultValue = finishAgent(prepared, parseApiResult(result, api))
57
+ return { outcome: resultValue.outcome, output: { ...resultValue.output } }
58
+ } catch (error) {
59
+ if (error instanceof AgentMethodError) throw new OperationError(error.code, error.message)
60
+ throw error
61
+ }
62
+ }
63
+
64
+ export async function runAgentFlow(): Promise<void> {
65
+ await handle(agentFlow)
66
+ }