@things-factory/ai-client-base 10.1.203 → 10.1.206

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 (34) hide show
  1. package/dist-server/service/assistant/registry-assistant.d.ts +15 -1
  2. package/dist-server/service/assistant/registry-assistant.js +18 -5
  3. package/dist-server/service/assistant/registry-assistant.js.map +1 -1
  4. package/dist-server/service/providers/anthropic.js +3 -2
  5. package/dist-server/service/providers/anthropic.js.map +1 -1
  6. package/dist-server/service/providers/gemini.js +4 -3
  7. package/dist-server/service/providers/gemini.js.map +1 -1
  8. package/dist-server/service/providers/openai.js +4 -3
  9. package/dist-server/service/providers/openai.js.map +1 -1
  10. package/dist-server/service/providers/request-options.d.ts +4 -0
  11. package/dist-server/service/providers/request-options.js +7 -0
  12. package/dist-server/service/providers/request-options.js.map +1 -0
  13. package/dist-server/service/testing/tool-call-context.d.ts +3 -1
  14. package/dist-server/service/testing/tool-call-context.js +2 -0
  15. package/dist-server/service/testing/tool-call-context.js.map +1 -1
  16. package/dist-server/service/tool-registry/index.d.ts +1 -1
  17. package/dist-server/service/tool-registry/index.js.map +1 -1
  18. package/dist-server/service/tool-registry/types.d.ts +16 -0
  19. package/dist-server/service/tool-registry/types.js.map +1 -1
  20. package/dist-server/service/types.d.ts +5 -0
  21. package/dist-server/service/types.js.map +1 -1
  22. package/dist-server/tsconfig.tsbuildinfo +1 -1
  23. package/package.json +5 -5
  24. package/server/service/assistant/registry-assistant.test.ts +80 -0
  25. package/server/service/assistant/registry-assistant.ts +33 -7
  26. package/server/service/providers/anthropic.ts +3 -2
  27. package/server/service/providers/gemini.ts +4 -3
  28. package/server/service/providers/openai.ts +4 -3
  29. package/server/service/providers/request-options.ts +12 -0
  30. package/server/service/providers/request-signal.test.ts +77 -0
  31. package/server/service/testing/tool-call-context.ts +4 -1
  32. package/server/service/tool-registry/index.ts +1 -0
  33. package/server/service/tool-registry/types.ts +17 -0
  34. 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.203",
3
+ "version": "10.1.206",
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.203",
27
+ "@things-factory/auth-base": "^10.1.206",
28
28
  "@things-factory/env": "^10.1.149",
29
- "@things-factory/setting-base": "^10.1.203",
30
- "@things-factory/shell": "^10.1.188",
29
+ "@things-factory/setting-base": "^10.1.206",
30
+ "@things-factory/shell": "^10.1.206",
31
31
  "openai": "^4.0.0"
32
32
  },
33
- "gitHead": "031aaae3b75b2975e6bce007b3ad7d8d74fe60c8"
33
+ "gitHead": "18588a058a4a6cb21c379b2da5517e077c6442ba"
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 recordToolCall({ ...record, result, durationMs: Date.now() - started }, context)
206
+ await recorded({ ...record, result, durationMs: Date.now() - started }, context)
181
207
  return result
182
208
  } catch (e) {
183
- await recordToolCall({ ...record, error: e, durationMs: Date.now() - started }, context)
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
  }
@@ -42,6 +42,7 @@ export type {
42
42
  ExternalToolContext,
43
43
  ToolCallContext,
44
44
  ToolCallMode,
45
+ UnattendedRunRef,
45
46
  ToolTranslate,
46
47
  ToolAttachment,
47
48
  ToolImageAttachment,
@@ -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
 
@@ -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. */