@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/skills.ts
ADDED
|
@@ -0,0 +1,138 @@
|
|
|
1
|
+
import { constants } from 'node:fs'
|
|
2
|
+
import { lstat, open, opendir, realpath } from 'node:fs/promises'
|
|
3
|
+
import { dirname, isAbsolute, join, parse, relative, resolve } from 'node:path'
|
|
4
|
+
import { fileURLToPath } from 'node:url'
|
|
5
|
+
|
|
6
|
+
import { AgentMethodError } from './errors.js'
|
|
7
|
+
import type { SkillText } from './index.js'
|
|
8
|
+
import { compareUtf8, localName, snapshot } from './values.js'
|
|
9
|
+
|
|
10
|
+
/** Read explicit selections from an immutable package root, never from the CWD. */
|
|
11
|
+
export async function readPackageSkills(
|
|
12
|
+
packageRoot: URL,
|
|
13
|
+
names: readonly string[],
|
|
14
|
+
): Promise<readonly SkillText[]> {
|
|
15
|
+
const selection = snapshot(names, 'INVALID_INPUT')
|
|
16
|
+
if (
|
|
17
|
+
!Array.isArray(selection) ||
|
|
18
|
+
selection.some((name) => !localName(name)) ||
|
|
19
|
+
new Set(selection).size !== selection.length
|
|
20
|
+
)
|
|
21
|
+
invalid('Select unique Skill LocalNames')
|
|
22
|
+
if (selection.length > 64) exhausted('Skill selection exceeds 64 groups')
|
|
23
|
+
if (
|
|
24
|
+
!(packageRoot instanceof URL) ||
|
|
25
|
+
packageRoot.protocol !== 'file:' ||
|
|
26
|
+
packageRoot.search !== '' ||
|
|
27
|
+
packageRoot.hash !== '' ||
|
|
28
|
+
!packageRoot.pathname.endsWith('/')
|
|
29
|
+
) {
|
|
30
|
+
invalid('Supply an absolute file URL for the package directory')
|
|
31
|
+
}
|
|
32
|
+
let root: string
|
|
33
|
+
try {
|
|
34
|
+
root = fileURLToPath(packageRoot)
|
|
35
|
+
await assertRealDirectory(root)
|
|
36
|
+
} catch {
|
|
37
|
+
invalid('Package root must be a real directory without symlink components')
|
|
38
|
+
}
|
|
39
|
+
if (selection.length === 0) return Object.freeze([])
|
|
40
|
+
let fileCount = 0
|
|
41
|
+
let contentBytes = 0
|
|
42
|
+
let entries = 0
|
|
43
|
+
const skills: SkillText[] = []
|
|
44
|
+
const decoder = new TextDecoder('utf-8', { fatal: true, ignoreBOM: true })
|
|
45
|
+
try {
|
|
46
|
+
for (const name of [...selection].sort((a, b) =>
|
|
47
|
+
compareUtf8(a as string, b as string),
|
|
48
|
+
) as string[]) {
|
|
49
|
+
const directory = join(root!, 'skills', name)
|
|
50
|
+
await assertRealDirectory(directory)
|
|
51
|
+
const files: { path: string; text: string }[] = []
|
|
52
|
+
const visit = async (current: string): Promise<void> => {
|
|
53
|
+
const reader = await opendir(current)
|
|
54
|
+
for await (const entry of reader) {
|
|
55
|
+
entries += 1
|
|
56
|
+
if (entries > 4096) exhausted('Skill traversal exceeds 4,096 entries')
|
|
57
|
+
const path = join(current, entry.name)
|
|
58
|
+
if (Buffer.byteLength(relative(directory, path)) > 4096)
|
|
59
|
+
exhausted('Skill path exceeds 4,096 bytes')
|
|
60
|
+
const info = await lstat(path)
|
|
61
|
+
if (info.isSymbolicLink()) invalid('Skill trees must not contain symlinks')
|
|
62
|
+
if (info.isDirectory()) {
|
|
63
|
+
await assertRealDirectory(path)
|
|
64
|
+
await visit(path)
|
|
65
|
+
} else if (info.isFile()) {
|
|
66
|
+
fileCount += 1
|
|
67
|
+
contentBytes += info.size
|
|
68
|
+
if (fileCount > 1024 || contentBytes > 1_048_576)
|
|
69
|
+
exhausted('Selected Skills exceed 1,024 files or 1 MiB content')
|
|
70
|
+
if ((await realpath(path)) !== path) invalid('Skill file path contains a symlink')
|
|
71
|
+
const handle = await open(path, constants.O_RDONLY | constants.O_NOFOLLOW)
|
|
72
|
+
try {
|
|
73
|
+
const opened = await handle.stat()
|
|
74
|
+
if (
|
|
75
|
+
!opened.isFile() ||
|
|
76
|
+
opened.dev !== info.dev ||
|
|
77
|
+
opened.ino !== info.ino ||
|
|
78
|
+
opened.size !== info.size
|
|
79
|
+
) {
|
|
80
|
+
invalid('Skill file changed during reading')
|
|
81
|
+
}
|
|
82
|
+
const bytes = new Uint8Array(info.size + 1)
|
|
83
|
+
let length = 0
|
|
84
|
+
while (length < bytes.length) {
|
|
85
|
+
const chunk = await handle.read(bytes, length, bytes.length - length, length)
|
|
86
|
+
if (chunk.bytesRead === 0) break
|
|
87
|
+
length += chunk.bytesRead
|
|
88
|
+
}
|
|
89
|
+
if (length !== info.size || (await realpath(path)) !== path)
|
|
90
|
+
invalid('Skill file changed during reading')
|
|
91
|
+
files.push(
|
|
92
|
+
Object.freeze({
|
|
93
|
+
path: relative(directory, path).split('\\').join('/'),
|
|
94
|
+
text: decoder.decode(bytes.subarray(0, length)),
|
|
95
|
+
}),
|
|
96
|
+
)
|
|
97
|
+
} finally {
|
|
98
|
+
await handle.close()
|
|
99
|
+
}
|
|
100
|
+
} else {
|
|
101
|
+
invalid('Skill trees may contain only directories and regular files')
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
await visit(directory)
|
|
106
|
+
if (!files.some((file) => file.path === 'SKILL.md'))
|
|
107
|
+
invalid('Selected Skill requires SKILL.md')
|
|
108
|
+
files.sort((a, b) => compareUtf8(a.path, b.path))
|
|
109
|
+
skills.push(Object.freeze({ name, files: Object.freeze(files) }))
|
|
110
|
+
}
|
|
111
|
+
} catch (error) {
|
|
112
|
+
if (error instanceof AgentMethodError) throw error
|
|
113
|
+
invalid('Selected Skill is unavailable or is not valid UTF-8 text')
|
|
114
|
+
}
|
|
115
|
+
return Object.freeze(skills)
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
async function assertRealDirectory(path: string): Promise<void> {
|
|
119
|
+
const absolute = resolve(path)
|
|
120
|
+
if (!isAbsolute(path)) invalid('Package path must be absolute')
|
|
121
|
+
let cursor = parse(absolute).root
|
|
122
|
+
for (const part of relative(cursor, absolute).split('/').filter(Boolean)) {
|
|
123
|
+
cursor = join(cursor, part)
|
|
124
|
+
const info = await lstat(cursor)
|
|
125
|
+
if (!info.isDirectory() || info.isSymbolicLink())
|
|
126
|
+
invalid('Package directory path contains a symlink or non-directory')
|
|
127
|
+
}
|
|
128
|
+
if ((await realpath(absolute)) !== absolute || dirname(absolute) === absolute)
|
|
129
|
+
invalid('Select a package directory')
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
function invalid(message: string): never {
|
|
133
|
+
throw new AgentMethodError('INVALID_INPUT', message)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function exhausted(message: string): never {
|
|
137
|
+
throw new AgentMethodError('RESOURCE_EXHAUSTED', message)
|
|
138
|
+
}
|
package/src/values.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
import { AgentMethodError, type AgentMethodErrorCode } from './errors.js'
|
|
2
|
+
import { canonicalJson, decodeJson1, type JsonValue } from './json.js'
|
|
3
|
+
|
|
4
|
+
export function ordinaryRecord(value: unknown): Record<string, unknown> | undefined {
|
|
5
|
+
if (value === null || typeof value !== 'object' || Array.isArray(value)) return undefined
|
|
6
|
+
const prototype = Object.getPrototypeOf(value)
|
|
7
|
+
if (prototype !== Object.prototype && prototype !== null) return undefined
|
|
8
|
+
return value as Record<string, unknown>
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function exactKeys(value: object, expected: readonly string[]): boolean {
|
|
12
|
+
const actual = Object.keys(value).sort()
|
|
13
|
+
const sorted = [...expected].sort()
|
|
14
|
+
return actual.length === sorted.length && actual.every((name, index) => name === sorted[index])
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
export function sessionReference(value: unknown): value is string {
|
|
18
|
+
return (
|
|
19
|
+
typeof value === 'string' &&
|
|
20
|
+
value.length === 36 &&
|
|
21
|
+
/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/.test(value)
|
|
22
|
+
)
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function validSessionReceipt(value: unknown): boolean {
|
|
26
|
+
const record = ordinaryRecord(value)
|
|
27
|
+
return (
|
|
28
|
+
record !== undefined &&
|
|
29
|
+
((exactKeys(record, ['status', 'reason']) &&
|
|
30
|
+
record.status === 'unavailable' &&
|
|
31
|
+
['not-cleanly-closed', 'missing-history', 'unsupported-history', 'capacity'].includes(
|
|
32
|
+
record.reason as string,
|
|
33
|
+
)) ||
|
|
34
|
+
(exactKeys(record, ['status', 'reference']) &&
|
|
35
|
+
record.status === 'retained' &&
|
|
36
|
+
sessionReference(record.reference)))
|
|
37
|
+
)
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export function snapshot(value: unknown, code: AgentMethodErrorCode): JsonValue {
|
|
41
|
+
try {
|
|
42
|
+
return decodeJson1(canonicalJson(value as JsonValue))
|
|
43
|
+
} catch {
|
|
44
|
+
throw new AgentMethodError(code, 'Agent method values must be ordinary bounded JSON/0 data')
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export function freezeJson<T extends JsonValue>(value: T): T {
|
|
49
|
+
if (value !== null && typeof value === 'object') {
|
|
50
|
+
for (const child of Object.values(value)) freezeJson(child)
|
|
51
|
+
Object.freeze(value)
|
|
52
|
+
}
|
|
53
|
+
return value
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function compareUtf8(left: string, right: string): number {
|
|
57
|
+
const a = new TextEncoder().encode(left)
|
|
58
|
+
const b = new TextEncoder().encode(right)
|
|
59
|
+
for (let index = 0; index < Math.min(a.length, b.length); index += 1) {
|
|
60
|
+
if (a[index] !== b[index]) return a[index]! - b[index]!
|
|
61
|
+
}
|
|
62
|
+
return a.length - b.length
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export function localName(name: unknown): name is string {
|
|
66
|
+
return typeof name === 'string' && name.length <= 64 && /^[a-z0-9]+(?:-[a-z0-9]+)*$/.test(name)
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function skillPath(path: unknown): path is string {
|
|
70
|
+
return (
|
|
71
|
+
typeof path === 'string' &&
|
|
72
|
+
new TextEncoder().encode(path).byteLength <= 4096 &&
|
|
73
|
+
!path.includes('\\') &&
|
|
74
|
+
!path.includes('\0') &&
|
|
75
|
+
path.split('/').every((part) => part.length > 0 && part !== '.' && part !== '..')
|
|
76
|
+
)
|
|
77
|
+
}
|
package/test/api.test.ts
ADDED
|
@@ -0,0 +1,326 @@
|
|
|
1
|
+
import { expect, test } from 'bun:test'
|
|
2
|
+
import { readFile } from 'node:fs/promises'
|
|
3
|
+
import { parseApiResult, prepareApiRequest } from '../src/api.js'
|
|
4
|
+
import { finishAgent, prepareAgent } from '../src/index.js'
|
|
5
|
+
|
|
6
|
+
const prepared = prepareAgent({ instructions: 'Answer.' })
|
|
7
|
+
const chatResult = (value: unknown) => parseApiResult(value, 'chat-completions')
|
|
8
|
+
const response = (content: unknown = 'Answer.', reason: unknown = 'stop', extra = {}) => ({
|
|
9
|
+
outcome: 'done',
|
|
10
|
+
output: {
|
|
11
|
+
status: 200,
|
|
12
|
+
body: {
|
|
13
|
+
object: 'chat.completion',
|
|
14
|
+
choices: [
|
|
15
|
+
{ index: 0, finish_reason: reason, message: { role: 'assistant', content, ...extra } },
|
|
16
|
+
],
|
|
17
|
+
},
|
|
18
|
+
},
|
|
19
|
+
})
|
|
20
|
+
|
|
21
|
+
test('request uses reviewed settings, has a token cap and no endpoint, key or tools', () => {
|
|
22
|
+
const { api, body } = prepareApiRequest(prepared, {
|
|
23
|
+
model: 'chosen-model',
|
|
24
|
+
maxCompletionTokens: 32,
|
|
25
|
+
})
|
|
26
|
+
expect(api).toBe('chat-completions')
|
|
27
|
+
expect(body).toEqual({
|
|
28
|
+
model: 'chosen-model',
|
|
29
|
+
max_completion_tokens: 32,
|
|
30
|
+
n: 1,
|
|
31
|
+
stream: false,
|
|
32
|
+
store: false,
|
|
33
|
+
messages: [{ role: 'user', content: prepared.request.prompt }],
|
|
34
|
+
})
|
|
35
|
+
for (const settings of [
|
|
36
|
+
{},
|
|
37
|
+
{ model: '' },
|
|
38
|
+
{ model: 'x', endpoint: 'https://example.org' },
|
|
39
|
+
{ model: 'x', maxCompletionTokens: 0 },
|
|
40
|
+
{ model: 'x', maxCompletionTokens: 65537 },
|
|
41
|
+
{ model: 'x', maxCompletionTokens: 1.5 },
|
|
42
|
+
{ model: 'x', maxCompletionTokens: null },
|
|
43
|
+
{ model: 'x', api: 'automatic' },
|
|
44
|
+
{ model: 'x', api: null },
|
|
45
|
+
{ model: 'x', structuredOutput: 'automatic' },
|
|
46
|
+
{ model: 'x', structuredOutput: null },
|
|
47
|
+
{ model: 'x', max_tokens: 32 },
|
|
48
|
+
{ model: 'x', tools: [] },
|
|
49
|
+
])
|
|
50
|
+
expect(() => prepareApiRequest(prepared, settings)).toThrow()
|
|
51
|
+
expect(() =>
|
|
52
|
+
prepareApiRequest(prepareAgent({ instructions: 'é'.repeat(150000) }), { model: 'x' }),
|
|
53
|
+
).not.toThrow()
|
|
54
|
+
})
|
|
55
|
+
|
|
56
|
+
test('Responses requests select the exact wire format without changing endpoint authority', () => {
|
|
57
|
+
expect(prepareApiRequest(prepared, { api: 'responses', model: 'chosen-model' })).toEqual({
|
|
58
|
+
api: 'responses',
|
|
59
|
+
body: {
|
|
60
|
+
model: 'chosen-model',
|
|
61
|
+
input: prepared.request.prompt,
|
|
62
|
+
max_output_tokens: 4096,
|
|
63
|
+
stream: false,
|
|
64
|
+
store: false,
|
|
65
|
+
},
|
|
66
|
+
})
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
const schema = {
|
|
70
|
+
$schema: 'https://flow.jig.md/schemas/schema-0.json',
|
|
71
|
+
type: 'object',
|
|
72
|
+
properties: { answer: { type: 'string' } },
|
|
73
|
+
required: ['answer'],
|
|
74
|
+
additionalProperties: false,
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
test('provider-enforced schemas require explicit selection and never replace local checking', () => {
|
|
78
|
+
const structured = prepareAgent({ instructions: 'Answer.', responseSchema: schema })
|
|
79
|
+
const { $schema: _removed, ...projected } = schema
|
|
80
|
+
for (const api of ['chat-completions', 'responses'] as const) {
|
|
81
|
+
const ordinary = prepareApiRequest(structured, { api, model: 'x' }).body
|
|
82
|
+
expect(ordinary.response_format).toBeUndefined()
|
|
83
|
+
expect(ordinary.text).toBeUndefined()
|
|
84
|
+
expect(
|
|
85
|
+
prepareApiRequest(structured, { api, model: 'x', structuredOutput: 'prompt' }).body,
|
|
86
|
+
).toEqual(ordinary)
|
|
87
|
+
const body = prepareApiRequest(structured, {
|
|
88
|
+
api,
|
|
89
|
+
model: 'x',
|
|
90
|
+
structuredOutput: 'json-schema',
|
|
91
|
+
}).body
|
|
92
|
+
const definition = { name: 'flow_agent_result', schema: projected, strict: true }
|
|
93
|
+
if (api === 'responses')
|
|
94
|
+
expect(body.text).toEqual({ format: { type: 'json_schema', ...definition } })
|
|
95
|
+
else expect(body.response_format).toEqual({ type: 'json_schema', json_schema: definition })
|
|
96
|
+
expect(
|
|
97
|
+
prepareApiRequest(prepared, { api, model: 'x', structuredOutput: 'json-schema' }).body,
|
|
98
|
+
).toEqual(prepareApiRequest(prepared, { api, model: 'x' }).body)
|
|
99
|
+
}
|
|
100
|
+
expect(structured.request.responseSchema?.$schema).toBe(schema.$schema)
|
|
101
|
+
})
|
|
102
|
+
|
|
103
|
+
test('HTTP and malformed replies remain failures, without echoing provider data', () => {
|
|
104
|
+
for (const status of [301, 400, 401, 429, 500]) {
|
|
105
|
+
expect(() =>
|
|
106
|
+
chatResult({ outcome: 'done', output: { status, body: 'private-provider-error' } }),
|
|
107
|
+
).toThrow(`HTTP ${status}`)
|
|
108
|
+
}
|
|
109
|
+
for (const result of [
|
|
110
|
+
response(null),
|
|
111
|
+
response('x', 'tool_calls'),
|
|
112
|
+
response('x', null),
|
|
113
|
+
response('x', 'stop', { tool_calls: [{ id: 'call', type: 'function' }] }),
|
|
114
|
+
response(['not text']),
|
|
115
|
+
{ outcome: 'done', output: { status: 200, body: '{"choices":[],"choices":[]}' } },
|
|
116
|
+
{ outcome: 'done', output: { status: 200, body: 'not json' } },
|
|
117
|
+
])
|
|
118
|
+
expect(() => chatResult(result)).toThrow()
|
|
119
|
+
})
|
|
120
|
+
|
|
121
|
+
test('complete answers, refusals and exhausted completions retain distinct outcomes', () => {
|
|
122
|
+
expect(chatResult(response('Answer.', 'stop', { tool_calls: [] })).output.text).toBe('Answer.')
|
|
123
|
+
expect(finishAgent(prepared, chatResult(response()))).toEqual({
|
|
124
|
+
outcome: 'done',
|
|
125
|
+
output: { text: 'Answer.' },
|
|
126
|
+
})
|
|
127
|
+
expect(finishAgent(prepared, chatResult(response('partial', 'length')))).toEqual({
|
|
128
|
+
outcome: 'limit',
|
|
129
|
+
output: { text: 'partial' },
|
|
130
|
+
})
|
|
131
|
+
expect(
|
|
132
|
+
finishAgent(prepared, chatResult(response(null, 'stop', { refusal: 'Cannot comply.' }))),
|
|
133
|
+
).toEqual({ outcome: 'blocked', output: { text: 'Cannot comply.' } })
|
|
134
|
+
expect(finishAgent(prepared, chatResult(response(null, 'content_filter'))).outcome).toBe(
|
|
135
|
+
'blocked',
|
|
136
|
+
)
|
|
137
|
+
})
|
|
138
|
+
|
|
139
|
+
test('the method validates structured output independently of the provider finish reason', () => {
|
|
140
|
+
const request = prepareAgent({
|
|
141
|
+
instructions: 'Answer.',
|
|
142
|
+
responseSchema: {
|
|
143
|
+
$schema: 'https://flow.jig.md/schemas/schema-0.json',
|
|
144
|
+
type: 'object',
|
|
145
|
+
properties: { answer: { type: 'string' } },
|
|
146
|
+
required: ['answer'],
|
|
147
|
+
additionalProperties: false,
|
|
148
|
+
},
|
|
149
|
+
})
|
|
150
|
+
expect(finishAgent(request, chatResult(response('{"answer":"yes"}'))).output.structured).toEqual({
|
|
151
|
+
answer: 'yes',
|
|
152
|
+
})
|
|
153
|
+
expect(() => finishAgent(request, chatResult(response('{"answer":42}')))).toThrow(
|
|
154
|
+
'responseSchema',
|
|
155
|
+
)
|
|
156
|
+
expect(() => finishAgent(request, chatResult(response('not JSON')))).toThrow('JSON/0')
|
|
157
|
+
})
|
|
158
|
+
|
|
159
|
+
test('ordinary Agent declares its packaged HTTP descriptor', async () => {
|
|
160
|
+
const contract = JSON.parse(
|
|
161
|
+
await readFile(new URL('../contracts/http-request/contract.json', import.meta.url), 'utf8'),
|
|
162
|
+
)
|
|
163
|
+
expect(contract.id).toBe('https://jig.md/contracts/http-request')
|
|
164
|
+
const meta = JSON.parse(await readFile(new URL('../FLOW.meta.json', import.meta.url), 'utf8'))
|
|
165
|
+
expect(meta.uses).toEqual({ http: { contract: './contracts/http-request/contract.json' } })
|
|
166
|
+
})
|
|
167
|
+
|
|
168
|
+
const responsesResult = (value: unknown) => parseApiResult(value, 'responses')
|
|
169
|
+
const responses = (output: unknown, status: unknown = 'completed', extra = {}) => ({
|
|
170
|
+
outcome: 'done',
|
|
171
|
+
output: {
|
|
172
|
+
status: 200,
|
|
173
|
+
body: { object: 'response', status, output, ...extra },
|
|
174
|
+
},
|
|
175
|
+
})
|
|
176
|
+
const message = (content: unknown, status = 'completed', role = 'assistant') => ({
|
|
177
|
+
type: 'message',
|
|
178
|
+
role,
|
|
179
|
+
status,
|
|
180
|
+
content,
|
|
181
|
+
})
|
|
182
|
+
const text = (value: unknown) => ({ type: 'output_text', text: value })
|
|
183
|
+
|
|
184
|
+
test('Responses separates completed text, explicit refusal and actual token exhaustion', () => {
|
|
185
|
+
expect(
|
|
186
|
+
finishAgent(
|
|
187
|
+
prepared,
|
|
188
|
+
responsesResult(
|
|
189
|
+
responses([
|
|
190
|
+
{ type: 'reasoning', summary: [{ type: 'summary_text', text: 'not public output' }] },
|
|
191
|
+
message([text('First '), text('answer.')]),
|
|
192
|
+
message([text(' Second answer.')]),
|
|
193
|
+
]),
|
|
194
|
+
),
|
|
195
|
+
),
|
|
196
|
+
).toEqual({ outcome: 'done', output: { text: 'First answer. Second answer.' } })
|
|
197
|
+
expect(
|
|
198
|
+
finishAgent(
|
|
199
|
+
prepared,
|
|
200
|
+
responsesResult(responses([message([{ type: 'refusal', refusal: 'Cannot comply.' }])])),
|
|
201
|
+
),
|
|
202
|
+
).toEqual({ outcome: 'blocked', output: { text: 'Cannot comply.' } })
|
|
203
|
+
expect(
|
|
204
|
+
finishAgent(
|
|
205
|
+
prepared,
|
|
206
|
+
responsesResult(
|
|
207
|
+
responses([message([text('Partial')], 'incomplete')], 'incomplete', {
|
|
208
|
+
incomplete_details: { reason: 'max_output_tokens' },
|
|
209
|
+
}),
|
|
210
|
+
),
|
|
211
|
+
),
|
|
212
|
+
).toEqual({
|
|
213
|
+
outcome: 'limit',
|
|
214
|
+
output: { text: 'Partial' },
|
|
215
|
+
})
|
|
216
|
+
expect(
|
|
217
|
+
finishAgent(
|
|
218
|
+
prepared,
|
|
219
|
+
responsesResult(
|
|
220
|
+
responses([], 'incomplete', {
|
|
221
|
+
incomplete_details: { reason: 'max_output_tokens' },
|
|
222
|
+
}),
|
|
223
|
+
),
|
|
224
|
+
),
|
|
225
|
+
).toEqual({ outcome: 'limit', output: { text: '' } })
|
|
226
|
+
expect(
|
|
227
|
+
finishAgent(
|
|
228
|
+
prepared,
|
|
229
|
+
responsesResult(
|
|
230
|
+
responses([], 'incomplete', {
|
|
231
|
+
incomplete_details: { reason: 'content_filter' },
|
|
232
|
+
}),
|
|
233
|
+
),
|
|
234
|
+
),
|
|
235
|
+
).toEqual({ outcome: 'blocked', output: { text: 'The Agent response was filtered.' } })
|
|
236
|
+
})
|
|
237
|
+
|
|
238
|
+
test('Responses rejects unsettled, failed, tool-bearing, malformed and inconsistent results', () => {
|
|
239
|
+
for (const value of [
|
|
240
|
+
responses([], 'queued'),
|
|
241
|
+
responses([], 'in_progress'),
|
|
242
|
+
responses([], 'failed', { error: { message: 'private-provider-error' } }),
|
|
243
|
+
responses([], 'cancelled'),
|
|
244
|
+
responses([message([text('Answer.')])], 'completed', {
|
|
245
|
+
error: { message: 'private-provider-error' },
|
|
246
|
+
}),
|
|
247
|
+
responses([], 'incomplete'),
|
|
248
|
+
responses([], 'incomplete', { incomplete_details: { reason: 'steered' } }),
|
|
249
|
+
responses([message([text('Answer.')])], 'completed', {
|
|
250
|
+
incomplete_details: { reason: 'max_output_tokens' },
|
|
251
|
+
}),
|
|
252
|
+
responses([message([text('Partial')], 'incomplete')]),
|
|
253
|
+
responses([message([text('Answer.')], 'in_progress')]),
|
|
254
|
+
responses([message([text('Answer.')], 'completed', 'user')]),
|
|
255
|
+
responses([message([text('Answer.')]), { type: 'function_call', name: 'run' }]),
|
|
256
|
+
responses([message([{ type: 'audio', data: 'secret' }])]),
|
|
257
|
+
responses([message([text(42)])]),
|
|
258
|
+
responses([null]),
|
|
259
|
+
responses([]),
|
|
260
|
+
responses([{ type: 'reasoning' }]),
|
|
261
|
+
response(),
|
|
262
|
+
]) {
|
|
263
|
+
let error: unknown
|
|
264
|
+
try {
|
|
265
|
+
responsesResult(value)
|
|
266
|
+
} catch (failure) {
|
|
267
|
+
error = failure
|
|
268
|
+
}
|
|
269
|
+
expect(error).toBeInstanceOf(Error)
|
|
270
|
+
expect(String(error)).not.toContain('private-provider-error')
|
|
271
|
+
}
|
|
272
|
+
expect(() => chatResult(responses([message([text('Answer.')])]))).toThrow()
|
|
273
|
+
})
|
|
274
|
+
|
|
275
|
+
test('Responses structured results are independently checked even after a completed response', () => {
|
|
276
|
+
const request = prepareAgent({ instructions: 'Answer.', responseSchema: schema })
|
|
277
|
+
expect(
|
|
278
|
+
finishAgent(request, responsesResult(responses([message([text('{"answer":"yes"}')])]))).output
|
|
279
|
+
.structured,
|
|
280
|
+
).toEqual({ answer: 'yes' })
|
|
281
|
+
for (const body of ['{"answer":42}', 'not JSON', '{"answer":"one","answer":"two"}'])
|
|
282
|
+
expect(() =>
|
|
283
|
+
finishAgent(request, responsesResult(responses([message([text(body)])]))),
|
|
284
|
+
).toThrow()
|
|
285
|
+
})
|
|
286
|
+
|
|
287
|
+
test('both APIs preserve the complete HTTP byte bounds and JSON/0 failure behavior', () => {
|
|
288
|
+
for (const api of ['chat-completions', 'responses'] as const) {
|
|
289
|
+
expect(() =>
|
|
290
|
+
prepareApiRequest(prepareAgent({ instructions: 'x'.repeat(262144) }), { api, model: 'x' }),
|
|
291
|
+
).not.toThrow()
|
|
292
|
+
expect(() =>
|
|
293
|
+
parseApiResult({ outcome: 'done', output: { status: 200, body: 'x'.repeat(1048577) } }, api),
|
|
294
|
+
).toThrow()
|
|
295
|
+
for (const body of [
|
|
296
|
+
'{"object":"response","object":"response"}',
|
|
297
|
+
'{"unsafe":9007199254740992}',
|
|
298
|
+
'{"bad":"\\ud800"}',
|
|
299
|
+
'not json',
|
|
300
|
+
])
|
|
301
|
+
expect(() =>
|
|
302
|
+
parseApiResult({ outcome: 'done', output: { status: 200, body } }, api),
|
|
303
|
+
).toThrow()
|
|
304
|
+
for (const status of [400, 401, 429, 500])
|
|
305
|
+
expect(() =>
|
|
306
|
+
parseApiResult(
|
|
307
|
+
{ outcome: 'done', output: { status, body: 'private-provider-error' } },
|
|
308
|
+
api,
|
|
309
|
+
),
|
|
310
|
+
).toThrow(`HTTP ${status}`)
|
|
311
|
+
}
|
|
312
|
+
})
|
|
313
|
+
|
|
314
|
+
test('decoded HTTP data preserves the full 8 MiB Agent text bound without JSON-string wrapping', () => {
|
|
315
|
+
const maximum = 'x'.repeat(8_388_608)
|
|
316
|
+
const replies = [
|
|
317
|
+
['chat-completions', response(maximum)],
|
|
318
|
+
['responses', responses([message([text(maximum)])])],
|
|
319
|
+
] as const
|
|
320
|
+
for (const [api, reply] of replies) {
|
|
321
|
+
expect(finishAgent(prepared, parseApiResult(reply, api)).output.text.length).toBe(
|
|
322
|
+
maximum.length,
|
|
323
|
+
)
|
|
324
|
+
}
|
|
325
|
+
expect(() => chatResult(response(`${maximum}x`))).toThrow('JSON/0')
|
|
326
|
+
})
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
import { handle, type JsonValue } from '@jigging/flow'
|
|
2
|
+
import { AgentConversationError, withAgentConversation } from '../src/conversation.js'
|
|
3
|
+
|
|
4
|
+
await handle(async (run) => {
|
|
5
|
+
const displayed: string[] = []
|
|
6
|
+
try {
|
|
7
|
+
const result = await withAgentConversation(
|
|
8
|
+
run,
|
|
9
|
+
{
|
|
10
|
+
operationId: 'dialogue',
|
|
11
|
+
slot: 'agent',
|
|
12
|
+
...(String(run.input).startsWith('observer')
|
|
13
|
+
? {
|
|
14
|
+
onEvent(event: import('../src/conversation.js').AgentUpdate) {
|
|
15
|
+
if (run.input === 'observer-throws') throw new Error('display failed')
|
|
16
|
+
if (run.input === 'observer-async')
|
|
17
|
+
return Promise.reject(new Error('async display failed'))
|
|
18
|
+
if (event.sessionUpdate === 'agent_message_chunk')
|
|
19
|
+
displayed.push(event.content.text)
|
|
20
|
+
},
|
|
21
|
+
}
|
|
22
|
+
: {}),
|
|
23
|
+
input: {
|
|
24
|
+
instructions: 'Draft from these facts',
|
|
25
|
+
...(['retained', 'missing-session', 'invalid-session'].includes(run.input as string)
|
|
26
|
+
? { session: { retain: true as const } }
|
|
27
|
+
: {}),
|
|
28
|
+
},
|
|
29
|
+
},
|
|
30
|
+
async (conversation) => {
|
|
31
|
+
if (run.input === 'abandoned') return null
|
|
32
|
+
const first = await conversation.initial
|
|
33
|
+
if (run.input === 'callback-error') throw new Error('application rejected draft')
|
|
34
|
+
const second = conversation.prompt({ instructions: 'Apply this correction' })
|
|
35
|
+
const control = ['interrupt', 'completion-race', 'not-running-race'].includes(
|
|
36
|
+
run.input as string,
|
|
37
|
+
)
|
|
38
|
+
? await conversation.interrupt()
|
|
39
|
+
: undefined
|
|
40
|
+
return { first, second: await second, ...(control === undefined ? {} : { control }) }
|
|
41
|
+
},
|
|
42
|
+
)
|
|
43
|
+
return {
|
|
44
|
+
outcome: 'done',
|
|
45
|
+
output: {
|
|
46
|
+
...result,
|
|
47
|
+
displayed,
|
|
48
|
+
...(result.observation?.status === 'incomplete'
|
|
49
|
+
? {
|
|
50
|
+
observation: {
|
|
51
|
+
status: 'incomplete',
|
|
52
|
+
errors: result.observation.errors.map((e) =>
|
|
53
|
+
e instanceof Error ? e.message : String(e),
|
|
54
|
+
),
|
|
55
|
+
},
|
|
56
|
+
}
|
|
57
|
+
: {}),
|
|
58
|
+
} as unknown as JsonValue,
|
|
59
|
+
}
|
|
60
|
+
} catch (error) {
|
|
61
|
+
if (!(error instanceof AgentConversationError)) throw error
|
|
62
|
+
return {
|
|
63
|
+
outcome: 'done',
|
|
64
|
+
output: {
|
|
65
|
+
errors: Array.from(error.errors, (value) =>
|
|
66
|
+
value instanceof Error ? value.message : String(value),
|
|
67
|
+
),
|
|
68
|
+
turns: error.turns as unknown as JsonValue,
|
|
69
|
+
settlement: (error.settlement as unknown as JsonValue) ?? null,
|
|
70
|
+
},
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
})
|