@things-factory/ai-client-base 10.1.203 → 10.1.205
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/dist-server/service/assistant/registry-assistant.d.ts +15 -1
- package/dist-server/service/assistant/registry-assistant.js +18 -5
- package/dist-server/service/assistant/registry-assistant.js.map +1 -1
- package/dist-server/service/providers/anthropic.js +3 -2
- package/dist-server/service/providers/anthropic.js.map +1 -1
- package/dist-server/service/providers/gemini.js +4 -3
- package/dist-server/service/providers/gemini.js.map +1 -1
- package/dist-server/service/providers/openai.js +4 -3
- package/dist-server/service/providers/openai.js.map +1 -1
- package/dist-server/service/providers/request-options.d.ts +4 -0
- package/dist-server/service/providers/request-options.js +7 -0
- package/dist-server/service/providers/request-options.js.map +1 -0
- package/dist-server/service/testing/tool-call-context.d.ts +3 -1
- package/dist-server/service/testing/tool-call-context.js +2 -0
- package/dist-server/service/testing/tool-call-context.js.map +1 -1
- package/dist-server/service/tool-registry/index.d.ts +1 -1
- package/dist-server/service/tool-registry/index.js.map +1 -1
- package/dist-server/service/tool-registry/types.d.ts +16 -0
- package/dist-server/service/tool-registry/types.js.map +1 -1
- package/dist-server/service/types.d.ts +5 -0
- package/dist-server/service/types.js.map +1 -1
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +5 -5
- package/server/service/assistant/registry-assistant.test.ts +80 -0
- package/server/service/assistant/registry-assistant.ts +33 -7
- package/server/service/providers/anthropic.ts +3 -2
- package/server/service/providers/gemini.ts +4 -3
- package/server/service/providers/openai.ts +4 -3
- package/server/service/providers/request-options.ts +12 -0
- package/server/service/providers/request-signal.test.ts +77 -0
- package/server/service/testing/tool-call-context.ts +4 -1
- package/server/service/tool-registry/index.ts +1 -0
- package/server/service/tool-registry/types.ts +17 -0
- package/server/service/types.ts +5 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/ai-client-base",
|
|
3
|
-
"version": "10.1.
|
|
3
|
+
"version": "10.1.205",
|
|
4
4
|
"main": "dist-server/index.js",
|
|
5
5
|
"things-factory": true,
|
|
6
6
|
"author": "heartyoh",
|
|
@@ -24,11 +24,11 @@
|
|
|
24
24
|
"dependencies": {
|
|
25
25
|
"@anthropic-ai/sdk": "^0.40.0",
|
|
26
26
|
"@google/generative-ai": "^0.24.0",
|
|
27
|
-
"@things-factory/auth-base": "^10.1.
|
|
27
|
+
"@things-factory/auth-base": "^10.1.205",
|
|
28
28
|
"@things-factory/env": "^10.1.149",
|
|
29
|
-
"@things-factory/setting-base": "^10.1.
|
|
30
|
-
"@things-factory/shell": "^10.1.
|
|
29
|
+
"@things-factory/setting-base": "^10.1.205",
|
|
30
|
+
"@things-factory/shell": "^10.1.205",
|
|
31
31
|
"openai": "^4.0.0"
|
|
32
32
|
},
|
|
33
|
-
"gitHead": "
|
|
33
|
+
"gitHead": "7311492aa5eaa00993c0325053f5f7ae8b1fb024"
|
|
34
34
|
}
|
|
@@ -545,3 +545,83 @@ describe('every call is handed to the recorders - as a digest (ADR-0061 decision
|
|
|
545
545
|
expect(recorded).toHaveLength(0)
|
|
546
546
|
})
|
|
547
547
|
})
|
|
548
|
+
|
|
549
|
+
describe('an unattended run goes through the same door (design/ai/unattended-run.md)', () => {
|
|
550
|
+
const recorded: Array<{ record: ToolCallRecord; context: any }> = []
|
|
551
|
+
const RUN = { id: 'run-1', source: 'office.strategy-review', subject: 'policy-9', scheduledAt: '2026-11-01T07:00:00.000Z' }
|
|
552
|
+
beforeEach(() => {
|
|
553
|
+
clearToolCallRecorders()
|
|
554
|
+
recorded.length = 0
|
|
555
|
+
registerToolCallRecorder((record, context) => {
|
|
556
|
+
recorded.push({ record, context })
|
|
557
|
+
})
|
|
558
|
+
registerToolCategory({
|
|
559
|
+
name: 'ledger',
|
|
560
|
+
guidance: 'Read the pay before saying it.',
|
|
561
|
+
specs: [
|
|
562
|
+
{
|
|
563
|
+
kind: 'read',
|
|
564
|
+
name: 'readPay',
|
|
565
|
+
description: 'pay lines',
|
|
566
|
+
doors: ['Query.pay'],
|
|
567
|
+
schema: { type: 'object', properties: {} },
|
|
568
|
+
builder: (args: any, context: any) => {
|
|
569
|
+
ran.push({ name: 'readPay', args, context })
|
|
570
|
+
if (args.fail) throw new Error('the pay of 김급여 could not be read')
|
|
571
|
+
return { read: 1, rows: [{ name: '김급여', net: 3_100_000 }] }
|
|
572
|
+
}
|
|
573
|
+
}
|
|
574
|
+
]
|
|
575
|
+
} as any)
|
|
576
|
+
})
|
|
577
|
+
afterAll(() => clearToolCallRecorders())
|
|
578
|
+
|
|
579
|
+
it('★ the run reaches the tool and the recorder as context.run - and a turn carries none', async () => {
|
|
580
|
+
const run = recordingClient([okResult(undefined, [TC('readPay')]), okResult('drafted')])
|
|
581
|
+
await registryAssistantChat(input({ client: run.client, toolCategories: ['ledger'], run: RUN }))
|
|
582
|
+
expect(ran[0].context.run).toEqual(RUN)
|
|
583
|
+
expect(recorded[0].context.run).toEqual(RUN)
|
|
584
|
+
|
|
585
|
+
const turn = recordingClient([okResult(undefined, [TC('readPay')]), okResult('answered')])
|
|
586
|
+
await registryAssistantChat(input({ client: turn.client, toolCategories: ['ledger'] }))
|
|
587
|
+
expect('run' in ran[1].context).toBe(false)
|
|
588
|
+
expect('run' in recorded[1].context).toBe(false)
|
|
589
|
+
})
|
|
590
|
+
|
|
591
|
+
it('★ onToolCall is handed the very digest the recorders get - one reading of a call, success and failure', async () => {
|
|
592
|
+
const seen: ToolCallRecord[] = []
|
|
593
|
+
const { client } = recordingClient([okResult(undefined, [TC('readPay', { employeeId: 'e-7' }, 'a'), TC('readPay', { fail: true }, 'b')]), okResult('drafted')])
|
|
594
|
+
await registryAssistantChat(input({ client, toolCategories: ['ledger'], run: RUN, onToolCall: (record: ToolCallRecord) => seen.push(record) }))
|
|
595
|
+
expect(seen).toHaveLength(2)
|
|
596
|
+
const strip = ({ durationMs: _d, ...rest }: any) => rest
|
|
597
|
+
expect(seen.map(strip)).toEqual(recorded.map(one => strip(one.record)))
|
|
598
|
+
expect(seen[0]).toMatchObject({ tool: 'readPay', targetIds: ['e-7'], ok: true, resultCount: 1 })
|
|
599
|
+
expect(seen[1]).toMatchObject({ tool: 'readPay', ok: false, errorCode: 'tool-threw', resultSize: 0 })
|
|
600
|
+
expect(JSON.stringify(seen)).not.toMatch(/김급여|3100000/)
|
|
601
|
+
})
|
|
602
|
+
|
|
603
|
+
it('an onToolCall that throws does not stop the run or the recorders', async () => {
|
|
604
|
+
const { client } = recordingClient([okResult(undefined, [TC('readPay')]), okResult('drafted')])
|
|
605
|
+
const result = await registryAssistantChat(input({ client, toolCategories: ['ledger'], onToolCall: () => { throw new Error('boom') } }))
|
|
606
|
+
expect(result.reply).toContain('drafted')
|
|
607
|
+
expect(recorded).toHaveLength(1)
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
it('★ the signal reaches every provider request, and the step limit holds with no wrap-up call', async () => {
|
|
611
|
+
const abort = new AbortController()
|
|
612
|
+
const { client, seen } = recordingClient([okResult(undefined, [TC('readPay')])])
|
|
613
|
+
const result = await registryAssistantChat(
|
|
614
|
+
input({ client, toolCategories: ['ledger'], signal: abort.signal, maxIterations: 3, salvageOnMaxIterations: false })
|
|
615
|
+
)
|
|
616
|
+
expect(seen).toHaveLength(3)
|
|
617
|
+
expect(seen.every(one => one.options.signal === abort.signal)).toBe(true)
|
|
618
|
+
expect(result.loop.abortReason).toMatchObject({ type: 'max_iterations' })
|
|
619
|
+
})
|
|
620
|
+
|
|
621
|
+
it('without the new inputs a turn keeps its limit and its wrap-up', async () => {
|
|
622
|
+
const { client, seen } = recordingClient([okResult(undefined, [TC('readPay')])])
|
|
623
|
+
await registryAssistantChat(input({ client, toolCategories: ['ledger'] }))
|
|
624
|
+
/* REGISTRY_MAX_ITERATIONS lookups and one wrap-up without tools. */
|
|
625
|
+
expect(seen).toHaveLength(REGISTRY_MAX_ITERATIONS + 1)
|
|
626
|
+
})
|
|
627
|
+
})
|
|
@@ -25,9 +25,9 @@ import { narrowToolScope, resolveToolScope, toolScopeGuidance, type ToolAccessCh
|
|
|
25
25
|
import { assistantChat, type AssistantChatResult } from './assistant-chat.js'
|
|
26
26
|
import { resultShapeOf } from './summarize.js'
|
|
27
27
|
import type { GroundingSources } from '../utils/index.js'
|
|
28
|
-
import type { ToolAttachment, ToolCallContext } from '../tool-registry/types.js'
|
|
28
|
+
import type { ToolAttachment, ToolCallContext, UnattendedRunRef } from '../tool-registry/types.js'
|
|
29
29
|
import { defaultValueTranslate } from '../tool-registry/translate.js'
|
|
30
|
-
import { recordToolCall } from '../tool-registry/call-recorder.js'
|
|
30
|
+
import { digestOf, recordToolCall, type ToolCallRecord, type ToolCallRun } from '../tool-registry/call-recorder.js'
|
|
31
31
|
|
|
32
32
|
|
|
33
33
|
/** Lookups a registry conversation may take before it answers from what it has (operato-twin's assistant takes 16 too). */
|
|
@@ -82,6 +82,19 @@ export interface RegistryAssistantInput {
|
|
|
82
82
|
attachments?: ToolAttachment[]
|
|
83
83
|
/** Texts the answer is allowed to draw identifiers from, besides the tool results. */
|
|
84
84
|
groundingSources?: Omit<GroundingSources, 'toolResults'>
|
|
85
|
+
/** Stops the loop and the provider request - an unattended run's time limit. */
|
|
86
|
+
signal?: AbortSignal
|
|
87
|
+
/** Lookups before the answer; `REGISTRY_MAX_ITERATIONS` when absent. */
|
|
88
|
+
maxIterations?: number
|
|
89
|
+
/** At the lookup limit, one more call without tools to answer from what was read. On when absent. */
|
|
90
|
+
salvageOnMaxIterations?: boolean
|
|
91
|
+
/**
|
|
92
|
+
* Each call as the recorders get it (`digestOf`, never its raw values) - an unattended run lists the reads its draft
|
|
93
|
+
* rested on from the same digest the audit keeps, so the two cannot describe one call differently.
|
|
94
|
+
*/
|
|
95
|
+
onToolCall?: (record: ToolCallRecord) => void
|
|
96
|
+
/** Set for an unattended run; tools and recorders see it as `context.run`. */
|
|
97
|
+
run?: UnattendedRunRef
|
|
85
98
|
}
|
|
86
99
|
|
|
87
100
|
export interface RegistryAssistantResult extends AssistantChatResult<unknown> {
|
|
@@ -139,7 +152,19 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
|
|
|
139
152
|
...(input.mode ? { mode: input.mode } : {}),
|
|
140
153
|
/* 번역기와 첨부는 서버의 값이다 — `host` 에서 받지 않는다(ADR-0061 2026-10-16 판정 1·2). */
|
|
141
154
|
t: input.t ?? defaultValueTranslate,
|
|
142
|
-
attachments: input.attachments ?? []
|
|
155
|
+
attachments: input.attachments ?? [],
|
|
156
|
+
...(input.run ? { run: input.run } : {})
|
|
157
|
+
}
|
|
158
|
+
|
|
159
|
+
const recorded = async (run: ToolCallRun, context: ToolCallContext) => {
|
|
160
|
+
if (input.onToolCall) {
|
|
161
|
+
try {
|
|
162
|
+
input.onToolCall(digestOf(run))
|
|
163
|
+
} catch (e) {
|
|
164
|
+
console.warn(`[registry-assistant] onToolCall failed for ${run.tool}:`, (e as Error)?.message ?? e)
|
|
165
|
+
}
|
|
166
|
+
}
|
|
167
|
+
await recordToolCall(run, context)
|
|
143
168
|
}
|
|
144
169
|
|
|
145
170
|
const result = await assistantChat<ToolCallContext, unknown>({
|
|
@@ -155,8 +180,9 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
|
|
|
155
180
|
* A question across a product's records takes many lookups. At the limit the model answers from
|
|
156
181
|
* what it read and says what is missing — the person is never told to split the question.
|
|
157
182
|
*/
|
|
158
|
-
maxIterations: REGISTRY_MAX_ITERATIONS,
|
|
159
|
-
salvageOnMaxIterations: true
|
|
183
|
+
maxIterations: input.maxIterations ?? REGISTRY_MAX_ITERATIONS,
|
|
184
|
+
salvageOnMaxIterations: input.salvageOnMaxIterations ?? true,
|
|
185
|
+
signal: input.signal
|
|
160
186
|
},
|
|
161
187
|
chat: (msgs, toolDefs, opts) => client.chatWithTools!(msgs, toolDefs, opts),
|
|
162
188
|
context: toolContext,
|
|
@@ -177,10 +203,10 @@ export async function registryAssistantChat(input: RegistryAssistantInput): Prom
|
|
|
177
203
|
const record = { tool: spec.name, category: categoryOf.get(spec.name) ?? '', args }
|
|
178
204
|
try {
|
|
179
205
|
const result = await (spec.builder as any)(args, context)
|
|
180
|
-
await
|
|
206
|
+
await recorded({ ...record, result, durationMs: Date.now() - started }, context)
|
|
181
207
|
return result
|
|
182
208
|
} catch (e) {
|
|
183
|
-
await
|
|
209
|
+
await recorded({ ...record, error: e, durationMs: Date.now() - started }, context)
|
|
184
210
|
throw e
|
|
185
211
|
}
|
|
186
212
|
},
|
|
@@ -20,6 +20,7 @@ import type {
|
|
|
20
20
|
} from '../types.js'
|
|
21
21
|
import { registerAIProvider } from '../factory.js'
|
|
22
22
|
import { parseJSONResponse } from '../utils/parse-json.js'
|
|
23
|
+
import { requestOptionsOf } from './request-options.js'
|
|
23
24
|
|
|
24
25
|
const DEFAULT_MODEL = 'claude-sonnet-4-6'
|
|
25
26
|
const DEFAULT_MAX_TOKENS = 4096
|
|
@@ -62,7 +63,7 @@ export class AnthropicAIClient implements AIClient {
|
|
|
62
63
|
...(system && { system }),
|
|
63
64
|
...(options.stopSequences && { stop_sequences: options.stopSequences }),
|
|
64
65
|
messages: conversation
|
|
65
|
-
} as any)
|
|
66
|
+
} as any, requestOptionsOf(options))
|
|
66
67
|
reportUsage(response, options, options.model ?? this.defaultModel)
|
|
67
68
|
return response
|
|
68
69
|
}
|
|
@@ -135,7 +136,7 @@ export class AnthropicAIClient implements AIClient {
|
|
|
135
136
|
messages: conversation,
|
|
136
137
|
tools: sdkTools,
|
|
137
138
|
...(toolChoice && { tool_choice: toolChoice })
|
|
138
|
-
} as any)
|
|
139
|
+
} as any, requestOptionsOf(options))
|
|
139
140
|
reportUsage(response, options, options.model ?? this.defaultModel)
|
|
140
141
|
|
|
141
142
|
const text = extractResponseText(response) || undefined
|
|
@@ -23,6 +23,7 @@ import type {
|
|
|
23
23
|
} from '../types.js'
|
|
24
24
|
import { registerAIProvider } from '../factory.js'
|
|
25
25
|
import { parseJSONResponse } from '../utils/parse-json.js'
|
|
26
|
+
import { requestOptionsOf } from './request-options.js'
|
|
26
27
|
|
|
27
28
|
// `gemini-3-pro` 는 2026-03-09 종료됨. 후속 권장 모델로 변경.
|
|
28
29
|
// 사용자가 자기 환경에서 활용 가능한 정확한 ID 는 ListModels 로 확인:
|
|
@@ -48,7 +49,7 @@ export class GeminiAIClient implements AIClient {
|
|
|
48
49
|
async chat(messages: AIMessage[], options: AIChatOptions = {}): Promise<string> {
|
|
49
50
|
const { systemPrompt, contents } = splitMessages(messages, options.systemPrompt)
|
|
50
51
|
const model = this.buildModel(options, systemPrompt)
|
|
51
|
-
const result = await model.generateContent({ contents } as any)
|
|
52
|
+
const result = await model.generateContent({ contents } as any, requestOptionsOf(options))
|
|
52
53
|
reportUsage(result, options, options.model ?? this.defaultModel)
|
|
53
54
|
return result.response.text()
|
|
54
55
|
}
|
|
@@ -70,7 +71,7 @@ export class GeminiAIClient implements AIClient {
|
|
|
70
71
|
...(systemPrompt && { systemInstruction: systemPrompt }),
|
|
71
72
|
generationConfig
|
|
72
73
|
}, this.requestOptions)
|
|
73
|
-
const result = await model.generateContent({ contents } as any)
|
|
74
|
+
const result = await model.generateContent({ contents } as any, requestOptionsOf(options))
|
|
74
75
|
reportUsage(result, options, options.model ?? this.defaultModel)
|
|
75
76
|
const text = result.response.text()
|
|
76
77
|
return parseJSONResponse<T>(text, { cutShort: result.response.candidates?.[0]?.finishReason === 'MAX_TOKENS' })
|
|
@@ -128,7 +129,7 @@ export class GeminiAIClient implements AIClient {
|
|
|
128
129
|
...(toolConfig && { toolConfig })
|
|
129
130
|
} as any, this.requestOptions)
|
|
130
131
|
|
|
131
|
-
const result = await model.generateContent({ contents } as any)
|
|
132
|
+
const result = await model.generateContent({ contents } as any, requestOptionsOf(options))
|
|
132
133
|
reportUsage(result, options, options.model ?? this.defaultModel)
|
|
133
134
|
const response: any = result.response
|
|
134
135
|
|
|
@@ -22,6 +22,7 @@ import type {
|
|
|
22
22
|
} from '../types.js'
|
|
23
23
|
import { registerAIProvider } from '../factory.js'
|
|
24
24
|
import { parseJSONResponse } from '../utils/parse-json.js'
|
|
25
|
+
import { requestOptionsOf } from './request-options.js'
|
|
25
26
|
|
|
26
27
|
const DEFAULT_MODEL = 'gpt-4o'
|
|
27
28
|
const DEFAULT_MAX_TOKENS = 4096
|
|
@@ -50,7 +51,7 @@ export class OpenAIAIClient implements AIClient {
|
|
|
50
51
|
...(options.temperature !== undefined && { temperature: options.temperature }),
|
|
51
52
|
...(options.stopSequences && { stop: options.stopSequences }),
|
|
52
53
|
messages: sdkMessages as any
|
|
53
|
-
})
|
|
54
|
+
}, requestOptionsOf(options))
|
|
54
55
|
reportUsage(response, options, options.model ?? this.defaultModel)
|
|
55
56
|
return response.choices[0]?.message?.content ?? ''
|
|
56
57
|
}
|
|
@@ -78,7 +79,7 @@ export class OpenAIAIClient implements AIClient {
|
|
|
78
79
|
...(options.temperature !== undefined && { temperature: options.temperature }),
|
|
79
80
|
response_format: { type: 'json_object' },
|
|
80
81
|
messages: sdkMessages as any
|
|
81
|
-
})
|
|
82
|
+
}, requestOptionsOf(options))
|
|
82
83
|
reportUsage(response, options, options.model ?? this.defaultModel)
|
|
83
84
|
const text = response.choices[0]?.message?.content ?? '{}'
|
|
84
85
|
return parseJSONResponse<T>(text, { cutShort: response.choices[0]?.finish_reason === 'length' })
|
|
@@ -131,7 +132,7 @@ export class OpenAIAIClient implements AIClient {
|
|
|
131
132
|
tools: sdkTools,
|
|
132
133
|
...(toolChoice !== undefined && { tool_choice: toolChoice as any }),
|
|
133
134
|
...(options.allowParallelToolCalls === false && { parallel_tool_calls: false })
|
|
134
|
-
})
|
|
135
|
+
}, requestOptionsOf(options))
|
|
135
136
|
reportUsage(response, options, options.model ?? this.defaultModel)
|
|
136
137
|
|
|
137
138
|
const choice = response.choices?.[0]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What a provider SDK is handed per request besides the body - the caller's abort signal (types.ts `signal`).
|
|
3
|
+
*
|
|
4
|
+
* The three adapters each take it the same way, so it is said once. Aborting stops our wait and frees the loop; the
|
|
5
|
+
* provider may still finish the request on its side and bill it, which is why a run's spend is capped by its token
|
|
6
|
+
* limit and not by its clock.
|
|
7
|
+
*/
|
|
8
|
+
import type { AIChatOptions } from '../types.js'
|
|
9
|
+
|
|
10
|
+
export function requestOptionsOf(options: AIChatOptions | undefined): { signal: AbortSignal } | undefined {
|
|
11
|
+
return options?.signal ? { signal: options.signal } : undefined
|
|
12
|
+
}
|
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* What this guards: a stop reaches the provider request.
|
|
3
|
+
*
|
|
4
|
+
* The loop passed `signal` on every request and no adapter handed it to its SDK, so a person's stop and an unattended
|
|
5
|
+
* run's time limit ended our side while the request kept running (2026-10-20). Each adapter's SDK is replaced with a
|
|
6
|
+
* stand-in that keeps what it was handed per request, for every call that sends one.
|
|
7
|
+
*/
|
|
8
|
+
import { AnthropicAIClient } from './anthropic'
|
|
9
|
+
import { OpenAIAIClient } from './openai'
|
|
10
|
+
import { GeminiAIClient } from './gemini'
|
|
11
|
+
|
|
12
|
+
type Seen = Array<{ body: any; request: any }>
|
|
13
|
+
|
|
14
|
+
function anthropic(seen: Seen) {
|
|
15
|
+
const client: any = new AnthropicAIClient({ provider: 'anthropic', apiKey: 'test-key' } as any)
|
|
16
|
+
client.sdk = {
|
|
17
|
+
messages: {
|
|
18
|
+
create: async (body: any, request: any) => {
|
|
19
|
+
seen.push({ body, request })
|
|
20
|
+
return { model: 'm', content: [{ type: 'text', text: '{"ok":true}' }], stop_reason: 'end_turn', usage: {} }
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
return client
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
function openai(seen: Seen) {
|
|
28
|
+
const client: any = new OpenAIAIClient({ provider: 'openai', apiKey: 'test-key' } as any)
|
|
29
|
+
client.sdk = {
|
|
30
|
+
chat: {
|
|
31
|
+
completions: {
|
|
32
|
+
create: async (body: any, request: any) => {
|
|
33
|
+
seen.push({ body, request })
|
|
34
|
+
return { model: 'm', choices: [{ message: { content: '{"ok":true}' }, finish_reason: 'stop' }], usage: {} }
|
|
35
|
+
}
|
|
36
|
+
}
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
return client
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
function gemini(seen: Seen) {
|
|
43
|
+
const client: any = new GeminiAIClient({ provider: 'gemini', apiKey: 'test-key' } as any)
|
|
44
|
+
client.sdk = {
|
|
45
|
+
getGenerativeModel: () => ({
|
|
46
|
+
generateContent: async (body: any, request: any) => {
|
|
47
|
+
seen.push({ body, request })
|
|
48
|
+
const response = { candidates: [{ content: { parts: [{ text: '{"ok":true}' }] }, finishReason: 'STOP' }], text: () => '{"ok":true}' }
|
|
49
|
+
return { response }
|
|
50
|
+
}
|
|
51
|
+
})
|
|
52
|
+
}
|
|
53
|
+
return client
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
const ADAPTERS = { anthropic, openai, gemini }
|
|
57
|
+
const tools = [{ name: 'readGoals', description: 'goals', parameters: { type: 'object', properties: {} } }]
|
|
58
|
+
const user = [{ role: 'user' as const, content: 'hello' }]
|
|
59
|
+
|
|
60
|
+
describe.each(Object.entries(ADAPTERS))('%s', (_name, make) => {
|
|
61
|
+
it('★ chat, generateJSON and chatWithTools hand the signal to the SDK request', async () => {
|
|
62
|
+
const seen: Seen = []
|
|
63
|
+
const client = make(seen)
|
|
64
|
+
const { signal } = new AbortController()
|
|
65
|
+
await client.chat(user, { signal })
|
|
66
|
+
await client.generateJSON('give json', { signal })
|
|
67
|
+
await client.chatWithTools(user, tools, { signal })
|
|
68
|
+
expect(seen).toHaveLength(3)
|
|
69
|
+
expect(seen.every(one => one.request?.signal === signal)).toBe(true)
|
|
70
|
+
})
|
|
71
|
+
|
|
72
|
+
it('without a signal the SDK is handed nothing extra', async () => {
|
|
73
|
+
const seen: Seen = []
|
|
74
|
+
await make(seen).chatWithTools(user, tools, {})
|
|
75
|
+
expect(seen[0].request).toBeUndefined()
|
|
76
|
+
})
|
|
77
|
+
})
|
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
* defines the neutral door, and that door's tests check it hands tools exactly this shape — so a tool
|
|
8
8
|
* test built on it fails when the tool reads a field no door fills.
|
|
9
9
|
*/
|
|
10
|
-
import type { ToolAttachment, ToolCallContext, ToolCallMode, ToolTranslate } from '../tool-registry/types.js'
|
|
10
|
+
import type { ToolAttachment, ToolCallContext, ToolCallMode, ToolTranslate, UnattendedRunRef } from '../tool-registry/types.js'
|
|
11
11
|
import { defaultValueTranslate } from '../tool-registry/translate.js'
|
|
12
12
|
|
|
13
13
|
export interface ToolCallContextFixtureInput {
|
|
@@ -21,6 +21,8 @@ export interface ToolCallContextFixtureInput {
|
|
|
21
21
|
t?: ToolTranslate
|
|
22
22
|
/** The checked files of the turn. Absent: none. */
|
|
23
23
|
attachments?: ToolAttachment[]
|
|
24
|
+
/** The unattended run the call belongs to. Absent: a turn of a conversation. */
|
|
25
|
+
run?: UnattendedRunRef
|
|
24
26
|
}
|
|
25
27
|
|
|
26
28
|
export function toolCallContextFixture(input: ToolCallContextFixtureInput = {}): ToolCallContext {
|
|
@@ -31,5 +33,6 @@ export function toolCallContextFixture(input: ToolCallContextFixtureInput = {}):
|
|
|
31
33
|
attachments: input.attachments ?? []
|
|
32
34
|
}
|
|
33
35
|
if (input.mode) context.mode = input.mode
|
|
36
|
+
if (input.run) context.run = input.run
|
|
34
37
|
return context
|
|
35
38
|
}
|
|
@@ -69,6 +69,12 @@ export interface ToolCallContext {
|
|
|
69
69
|
* reads every entry of `kind: 'image'` — it does not pick the first one.
|
|
70
70
|
*/
|
|
71
71
|
attachments: ToolAttachment[]
|
|
72
|
+
/**
|
|
73
|
+
* Set when no person is in this conversation: a scheduled run of the mode, as the caller named it
|
|
74
|
+
* (operato-application/design/ai/unattended-run.md §6). A recorder tells an unattended run's calls from a turn's by
|
|
75
|
+
* it; a turn has none. The server's value - never from `host`.
|
|
76
|
+
*/
|
|
77
|
+
run?: UnattendedRunRef
|
|
72
78
|
}
|
|
73
79
|
|
|
74
80
|
export interface ToolCallMode {
|
|
@@ -76,6 +82,17 @@ export interface ToolCallMode {
|
|
|
76
82
|
target?: string
|
|
77
83
|
}
|
|
78
84
|
|
|
85
|
+
/** Which unattended run a call belongs to - the stored run's id and the identity its caller gave it. */
|
|
86
|
+
export interface UnattendedRunRef {
|
|
87
|
+
id: string
|
|
88
|
+
/** Who scheduled it - `<app>.<work>`, e.g. `office.strategy-review`. */
|
|
89
|
+
source: string
|
|
90
|
+
/** What it is about in the source's terms - a policy id. */
|
|
91
|
+
subject: string
|
|
92
|
+
/** The occurrence it is, ISO 8601. */
|
|
93
|
+
scheduledAt: string
|
|
94
|
+
}
|
|
95
|
+
|
|
79
96
|
/** `t(key, { defaultValue, ...params })` — the request's translator, as i18next's `t` is called. */
|
|
80
97
|
export type ToolTranslate = (key: string, params?: Record<string, any>) => string
|
|
81
98
|
|
package/server/service/types.ts
CHANGED
|
@@ -85,6 +85,11 @@ export interface AIChatOptions {
|
|
|
85
85
|
* call so the usage row knows whose call it was; adapters call it once for every request they send.
|
|
86
86
|
*/
|
|
87
87
|
onUsage?: (usage: AIProviderUsage) => void
|
|
88
|
+
/**
|
|
89
|
+
* Stops waiting for the request - a person's stop, or an unattended run's time limit. Every adapter hands it to its
|
|
90
|
+
* SDK. The provider may still complete and bill a request it already received.
|
|
91
|
+
*/
|
|
92
|
+
signal?: AbortSignal
|
|
88
93
|
}
|
|
89
94
|
|
|
90
95
|
/** What one provider request used, as the provider reported it. A count the provider did not report stays undefined. */
|