@jigging/agent-method 0.0.0 → 0.1.0-alpha.4
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 +98 -0
- package/FLOW.contract.json +241 -0
- package/FLOW.meta.json +8 -0
- package/FLOW.ts +3 -0
- package/LICENSE +373 -0
- package/README.md +369 -0
- package/THIRD_PARTY_NOTICES +9 -0
- package/contracts/acp-public-updates.json +75 -0
- package/contracts/agent-commands.json +88 -0
- package/contracts/agent-replies.json +202 -0
- package/contracts/http-request/contract.json +37 -0
- package/dist/api.d.ts +11 -0
- package/dist/api.js +164 -0
- package/dist/conversation.d.ts +68 -0
- package/dist/conversation.js +346 -0
- package/dist/errors.d.ts +5 -0
- package/dist/errors.js +8 -0
- package/dist/flow.d.ts +3 -0
- package/dist/flow.js +2784 -0
- package/dist/index.d.ts +67 -0
- package/dist/index.js +220 -0
- package/dist/json.d.ts +21 -0
- package/dist/json.js +409 -0
- package/dist/schema.d.ts +7 -0
- package/dist/schema.js +180 -0
- package/dist/skills.d.ts +3 -0
- package/dist/skills.js +132 -0
- package/dist/values.d.ts +11 -0
- package/dist/values.js +65 -0
- package/justfile +32 -0
- package/licenses/flow.LICENSE +202 -0
- package/package.json +45 -5
- package/settings.schema.json +12 -0
- package/skills/answer-check/SKILL.md +5 -0
- package/src/api.ts +191 -0
- package/src/conversation.ts +387 -0
- package/src/errors.ts +11 -0
- package/src/flow.ts +66 -0
- package/src/index.ts +325 -0
- package/src/json.ts +406 -0
- package/src/schema.ts +230 -0
- package/src/skills.ts +138 -0
- package/src/values.ts +77 -0
- package/test/api.test.ts +326 -0
- package/test/conversation-fixture.ts +73 -0
- package/test/conversation.test.ts +328 -0
- package/test/json.test.ts +88 -0
- package/test/method.test.ts +308 -0
- package/test/pack.test.ts +81 -0
- package/test/result.test.ts +103 -0
- package/test/skills-flow.test.ts +252 -0
- 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
|
+
}
|