@things-factory/ai-assistant 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-client/tsconfig.tsbuildinfo +1 -1
- package/dist-server/service/assistant-mode/index.d.ts +1 -1
- package/dist-server/service/assistant-mode/index.js.map +1 -1
- package/dist-server/service/assistant-mode/mode-categories.d.ts +10 -0
- package/dist-server/service/assistant-mode/mode-categories.js +10 -0
- package/dist-server/service/assistant-mode/mode-categories.js.map +1 -1
- package/dist-server/service/assistant-mode/types.d.ts +14 -0
- package/dist-server/service/assistant-mode/types.js.map +1 -1
- package/dist-server/service/index.d.ts +2 -1
- package/dist-server/service/index.js +4 -1
- package/dist-server/service/index.js.map +1 -1
- package/dist-server/service/unattended-run/assistant-run.d.ts +45 -0
- package/dist-server/service/unattended-run/assistant-run.js +248 -0
- package/dist-server/service/unattended-run/assistant-run.js.map +1 -0
- package/dist-server/service/unattended-run/index.d.ts +8 -0
- package/dist-server/service/unattended-run/index.js +15 -0
- package/dist-server/service/unattended-run/index.js.map +1 -0
- package/dist-server/service/unattended-run/limits.d.ts +9 -0
- package/dist-server/service/unattended-run/limits.js +24 -0
- package/dist-server/service/unattended-run/limits.js.map +1 -0
- package/dist-server/service/unattended-run/run-as.d.ts +24 -0
- package/dist-server/service/unattended-run/run-as.js +26 -0
- package/dist-server/service/unattended-run/run-as.js.map +1 -0
- package/dist-server/service/unattended-run/run-budget.d.ts +24 -0
- package/dist-server/service/unattended-run/run-budget.js +46 -0
- package/dist-server/service/unattended-run/run-budget.js.map +1 -0
- package/dist-server/service/unattended-run/run-ledger.d.ts +13 -0
- package/dist-server/service/unattended-run/run-ledger.js +29 -0
- package/dist-server/service/unattended-run/run-ledger.js.map +1 -0
- package/dist-server/service/unattended-run/run-outcome.d.ts +9 -0
- package/dist-server/service/unattended-run/run-outcome.js +46 -0
- package/dist-server/service/unattended-run/run-outcome.js.map +1 -0
- package/dist-server/service/unattended-run/run-store.d.ts +31 -0
- package/dist-server/service/unattended-run/run-store.js +108 -0
- package/dist-server/service/unattended-run/run-store.js.map +1 -0
- package/dist-server/service/unattended-run/run-unattended.d.ts +50 -0
- package/dist-server/service/unattended-run/run-unattended.js +214 -0
- package/dist-server/service/unattended-run/run-unattended.js.map +1 -0
- package/dist-server/service/unattended-run/unattended-prompt.d.ts +3 -0
- package/dist-server/service/unattended-run/unattended-prompt.js +27 -0
- package/dist-server/service/unattended-run/unattended-prompt.js.map +1 -0
- package/dist-server/tsconfig.tsbuildinfo +1 -1
- package/package.json +8 -8
- package/server/service/assistant-mode/index.ts +1 -1
- package/server/service/assistant-mode/mode-categories.ts +10 -0
- package/server/service/assistant-mode/types.ts +15 -0
- package/server/service/index.ts +4 -1
- package/server/service/unattended-run/assistant-run.ts +199 -0
- package/server/service/unattended-run/index.ts +10 -0
- package/server/service/unattended-run/limits.ts +24 -0
- package/server/service/unattended-run/run-as.ts +38 -0
- package/server/service/unattended-run/run-budget.ts +58 -0
- package/server/service/unattended-run/run-ledger.ts +31 -0
- package/server/service/unattended-run/run-outcome.ts +43 -0
- package/server/service/unattended-run/run-store-race.pg.test.ts +83 -0
- package/server/service/unattended-run/run-store.ts +162 -0
- package/server/service/unattended-run/run-unattended.test.ts +477 -0
- package/server/service/unattended-run/run-unattended.ts +246 -0
- package/server/service/unattended-run/unattended-prompt.ts +24 -0
- package/translations/en.json +15 -1
- package/translations/es.json +15 -1
- package/translations/ja.json +15 -1
- package/translations/ko.json +15 -1
- package/translations/ms.json +15 -1
- package/translations/zh.json +15 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@things-factory/ai-assistant",
|
|
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",
|
|
@@ -35,13 +35,13 @@
|
|
|
35
35
|
"@operato/shell": "^10.29.39",
|
|
36
36
|
"@operato/styles": "^10.0.0",
|
|
37
37
|
"@operato/utils": "^10.29.39",
|
|
38
|
-
"@things-factory/ai-client-base": "^10.1.
|
|
39
|
-
"@things-factory/apptool-base": "^10.1.
|
|
40
|
-
"@things-factory/attachment-base": "^10.1.
|
|
41
|
-
"@things-factory/auth-base": "^10.1.
|
|
38
|
+
"@things-factory/ai-client-base": "^10.1.205",
|
|
39
|
+
"@things-factory/apptool-base": "^10.1.205",
|
|
40
|
+
"@things-factory/attachment-base": "^10.1.205",
|
|
41
|
+
"@things-factory/auth-base": "^10.1.205",
|
|
42
42
|
"@things-factory/env": "^10.1.149",
|
|
43
|
-
"@things-factory/setting-base": "^10.1.
|
|
44
|
-
"@things-factory/shell": "^10.1.
|
|
43
|
+
"@things-factory/setting-base": "^10.1.205",
|
|
44
|
+
"@things-factory/shell": "^10.1.205",
|
|
45
45
|
"dompurify": "^3.0.0",
|
|
46
46
|
"exceljs": "4.2.0",
|
|
47
47
|
"graphql-scalars": "^1.22.0",
|
|
@@ -55,5 +55,5 @@
|
|
|
55
55
|
"copyfiles": "^2.4.1",
|
|
56
56
|
"rimraf": "^5.0.0"
|
|
57
57
|
},
|
|
58
|
-
"gitHead": "
|
|
58
|
+
"gitHead": "7311492aa5eaa00993c0325053f5f7ae8b1fb024"
|
|
59
59
|
}
|
|
@@ -13,5 +13,5 @@ export {
|
|
|
13
13
|
} from './registry.js'
|
|
14
14
|
export { resolveModeSettings, assistantModeView } from './resolve.js'
|
|
15
15
|
export { ABSENCE_RULE, NEXT_REQUEST_RULE, conversationPrompt, splitNextRequest } from './screen-rules.js'
|
|
16
|
-
export type { AssistantModeSpec, AssistantModeSlash, AssistantModeWarning } from './types.js'
|
|
16
|
+
export type { AssistantModeSpec, AssistantModeSlash, AssistantModeWarning, UnattendedLimits } from './types.js'
|
|
17
17
|
export type { AssistantModeRef, ModeSettings, AssistantModeView, AssistantModeSlashView, Translate } from './resolve.js'
|
|
@@ -13,3 +13,13 @@ export function modeToolCategories(mode: { toolCategories?: string[]; attachment
|
|
|
13
13
|
]
|
|
14
14
|
return [...new Set([...(mode.toolCategories ?? []), ...extra])]
|
|
15
15
|
}
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* The categories an unattended run of a mode opens: its own and the lists it reads (design/ai/unattended-run.md §3).
|
|
19
|
+
*
|
|
20
|
+
* Not the 입력안 tools - nobody is there to press the card - nor the AI 작업판 or the attachment reader: there is no
|
|
21
|
+
* conversation for a card or a file to belong to.
|
|
22
|
+
*/
|
|
23
|
+
export function unattendedToolCategories(mode: { toolCategories?: string[]; readTargets?: string[] }): string[] {
|
|
24
|
+
return [...new Set([...(mode.toolCategories ?? []), ...(mode.readTargets ?? []).map(key => `data-read:${key}`)])]
|
|
25
|
+
}
|
|
@@ -95,6 +95,21 @@ export interface AssistantModeSpec {
|
|
|
95
95
|
stagedNoticeKey?: string
|
|
96
96
|
/** Follow-up chips for a proposal that arrived without its own. */
|
|
97
97
|
proposalChoices?: any[]
|
|
98
|
+
/**
|
|
99
|
+
* This mode may run with no person in it - a scheduled draft (design/ai/unattended-run.md §4). Absent: it may not.
|
|
100
|
+
* The limits are the mode's, as every limit is (ADR-0061 decision 7); the caller does not name them.
|
|
101
|
+
*/
|
|
102
|
+
unattended?: UnattendedLimits
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
/** What one unattended run of a mode may spend. Each absent one takes the default in `unattended-run/limits.ts`. */
|
|
106
|
+
export interface UnattendedLimits {
|
|
107
|
+
/** Model requests of one run, the wrap-up included. */
|
|
108
|
+
maxModelCalls?: number
|
|
109
|
+
/** Input and output tokens of one run together. The request that crosses it is the last one. */
|
|
110
|
+
maxTokens?: number
|
|
111
|
+
/** Wall time of one run, model requests and tool calls together. */
|
|
112
|
+
timeoutMs?: number
|
|
98
113
|
}
|
|
99
114
|
|
|
100
115
|
/** A declaration that registered but is not what the contract asks for. Code + params, not prose. */
|
package/server/service/index.ts
CHANGED
|
@@ -13,6 +13,7 @@ import './chat-session/session-purge.js'
|
|
|
13
13
|
import './chat-session/attachment-route.js'
|
|
14
14
|
import { EntryProposalResolver } from './data-entry/entry-resolver.js'
|
|
15
15
|
import './workspace/workspace-tools.js'
|
|
16
|
+
import { entities as UnattendedRunEntities } from './unattended-run/index.js'
|
|
16
17
|
|
|
17
18
|
export * from './types.js'
|
|
18
19
|
export * from './chat-session-resolver.js'
|
|
@@ -61,8 +62,10 @@ export * from './chat-session-participant/mention-invite.js'
|
|
|
61
62
|
export * from './chat-session-participant/presence.js'
|
|
62
63
|
export * from './chat-message/index.js'
|
|
63
64
|
export * from './chat-session-participant/index.js'
|
|
65
|
+
/* A mode run with no person in it - a scheduled draft (operato-application/design/ai/unattended-run.md). */
|
|
66
|
+
export * from './unattended-run/index.js'
|
|
64
67
|
|
|
65
|
-
export const entities = [...ChatSessionEntities, ...ChatMessageEntities, ...ChatSessionParticipantEntities]
|
|
68
|
+
export const entities = [...ChatSessionEntities, ...ChatMessageEntities, ...ChatSessionParticipantEntities, ...UnattendedRunEntities]
|
|
66
69
|
|
|
67
70
|
export const schema = {
|
|
68
71
|
resolverClasses: [
|
|
@@ -0,0 +1,199 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* AssistantRun — one occurrence of a mode run with no person in it, and what it left (design/ai/unattended-run.md).
|
|
3
|
+
*
|
|
4
|
+
* The one place the facts of an AI run are kept: its attempts, its state, the draft, the reads the draft rested on,
|
|
5
|
+
* what it spent and the doors behind it. The application that scheduled it keeps its own facts and points here by
|
|
6
|
+
* (source, subject, scheduledAt) or by id - it does not copy these (chief architect ruling, 2026-10-20).
|
|
7
|
+
*/
|
|
8
|
+
import { Column, CreateDateColumn, Entity, Index, ManyToOne, PrimaryGeneratedColumn, RelationId, UpdateDateColumn } from 'typeorm'
|
|
9
|
+
import { Field, ID, Int, ObjectType } from 'type-graphql'
|
|
10
|
+
import { GraphQLJSON } from 'graphql-scalars'
|
|
11
|
+
|
|
12
|
+
import { Domain } from '@things-factory/shell'
|
|
13
|
+
import { User } from '@things-factory/auth-base'
|
|
14
|
+
import { config } from '@things-factory/env'
|
|
15
|
+
|
|
16
|
+
const ORMCONFIG = config.get('ormconfig', {})
|
|
17
|
+
const DATABASE_TYPE = ORMCONFIG.type
|
|
18
|
+
|
|
19
|
+
const LONG_TEXT = {
|
|
20
|
+
type:
|
|
21
|
+
DATABASE_TYPE == 'mysql' || DATABASE_TYPE == 'mariadb'
|
|
22
|
+
? 'longtext'
|
|
23
|
+
: DATABASE_TYPE == 'oracle'
|
|
24
|
+
? 'clob'
|
|
25
|
+
: DATABASE_TYPE == 'mssql'
|
|
26
|
+
? 'nvarchar'
|
|
27
|
+
: 'text',
|
|
28
|
+
length: DATABASE_TYPE == 'mssql' ? 'MAX' : undefined,
|
|
29
|
+
nullable: true
|
|
30
|
+
} as const
|
|
31
|
+
|
|
32
|
+
const parsed = (text?: string | null) => {
|
|
33
|
+
if (!text) return null
|
|
34
|
+
try {
|
|
35
|
+
return JSON.parse(text)
|
|
36
|
+
} catch {
|
|
37
|
+
return null
|
|
38
|
+
}
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
@Entity()
|
|
42
|
+
/* One occurrence, one row - two first calls at once make one row and the other reads it (§5). */
|
|
43
|
+
@Index('ix_assistant_run_0', (run: AssistantRun) => [run.domain, run.source, run.subject, run.scheduledAt], { unique: true })
|
|
44
|
+
@Index('ix_assistant_run_1', (run: AssistantRun) => [run.domain, run.source, run.subject, run.createdAt])
|
|
45
|
+
@ObjectType({ description: 'One scheduled run of an assistant mode with no person in it, and the draft it left.' })
|
|
46
|
+
export class AssistantRun {
|
|
47
|
+
@PrimaryGeneratedColumn('uuid')
|
|
48
|
+
@Field(type => ID, { description: 'Run id.' })
|
|
49
|
+
readonly id?: string
|
|
50
|
+
|
|
51
|
+
@ManyToOne(type => Domain)
|
|
52
|
+
@Field(type => Domain, { nullable: true, description: 'The domain the run is in.' })
|
|
53
|
+
domain?: Domain
|
|
54
|
+
|
|
55
|
+
@RelationId((run: AssistantRun) => run.domain)
|
|
56
|
+
domainId?: string
|
|
57
|
+
|
|
58
|
+
@Column()
|
|
59
|
+
@Field({ description: 'Readable name of the occurrence - source, subject and time.' })
|
|
60
|
+
name: string
|
|
61
|
+
|
|
62
|
+
@Column({ type: 'text', nullable: true })
|
|
63
|
+
@Field({ nullable: true, description: 'Description.' })
|
|
64
|
+
description?: string
|
|
65
|
+
|
|
66
|
+
@Column({ length: 64 })
|
|
67
|
+
@Field({ description: "Who scheduled it - '<app>.<work>', e.g. 'office.strategy-review'." })
|
|
68
|
+
source: string
|
|
69
|
+
|
|
70
|
+
@Column({ length: 64 })
|
|
71
|
+
@Field({ description: "What it is about in the source's terms, e.g. a policy id." })
|
|
72
|
+
subject: string
|
|
73
|
+
|
|
74
|
+
@Column()
|
|
75
|
+
@Field({ description: 'The occurrence this run is.' })
|
|
76
|
+
scheduledAt: Date
|
|
77
|
+
|
|
78
|
+
@Column({ length: 64 })
|
|
79
|
+
@Field({ description: 'The assistant mode that ran.' })
|
|
80
|
+
modeKind: string
|
|
81
|
+
|
|
82
|
+
@Column({ nullable: true })
|
|
83
|
+
@Field({ nullable: true, description: 'What the mode was about.' })
|
|
84
|
+
modeTarget?: string
|
|
85
|
+
|
|
86
|
+
@ManyToOne(type => User)
|
|
87
|
+
@Field(type => User, { nullable: true, description: 'The person the run acted as - its privileges and doors were theirs.' })
|
|
88
|
+
runAs?: User
|
|
89
|
+
|
|
90
|
+
@RelationId((run: AssistantRun) => run.runAs)
|
|
91
|
+
runAsId?: string
|
|
92
|
+
|
|
93
|
+
@Column({ length: 16 })
|
|
94
|
+
@Field({ description: "'running' | 'succeeded' | 'failed' | 'refused'." })
|
|
95
|
+
status: string
|
|
96
|
+
|
|
97
|
+
@Column({ type: 'int', default: 1 })
|
|
98
|
+
@Field(type => Int, { description: 'Which attempt at this occurrence the row holds, from 1.' })
|
|
99
|
+
attempt: number
|
|
100
|
+
|
|
101
|
+
@Column({ nullable: true })
|
|
102
|
+
@Field({ nullable: true, description: 'When this attempt started.' })
|
|
103
|
+
startedAt?: Date
|
|
104
|
+
|
|
105
|
+
@Column({ nullable: true })
|
|
106
|
+
@Field({ nullable: true, description: 'Past this a running attempt is taken as gone and the next call takes the occurrence over.' })
|
|
107
|
+
deadlineAt?: Date
|
|
108
|
+
|
|
109
|
+
@Column({ nullable: true })
|
|
110
|
+
@Field({ nullable: true, description: 'When this attempt ended.' })
|
|
111
|
+
finishedAt?: Date
|
|
112
|
+
|
|
113
|
+
@Column(LONG_TEXT as any)
|
|
114
|
+
@Field({ nullable: true, description: 'The draft - only when the run succeeded.' })
|
|
115
|
+
draft?: string
|
|
116
|
+
|
|
117
|
+
/** Each call of the run as the audit digests it (`ToolCallRecord`, never raw values), as JSON. */
|
|
118
|
+
@Column(LONG_TEXT as any)
|
|
119
|
+
reads?: string
|
|
120
|
+
|
|
121
|
+
@Field(() => GraphQLJSON, { nullable: true, description: 'The reads the draft rested on: tool, category, ids, rows, hash, ok or the error code.' })
|
|
122
|
+
get readsJson(): any {
|
|
123
|
+
return parsed(this.reads)
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
@Column({ type: 'simple-array', nullable: true })
|
|
127
|
+
@Field(() => [String], { nullable: true, description: 'GraphQL doors the reads went through. A reader of the draft must pass all of them.' })
|
|
128
|
+
doors?: string[]
|
|
129
|
+
|
|
130
|
+
@Column({ nullable: true })
|
|
131
|
+
@Field({ nullable: true, description: 'Why it failed or was refused - a code; the screen says it.' })
|
|
132
|
+
outcomeCode?: string
|
|
133
|
+
|
|
134
|
+
@Column({ type: 'text', nullable: true })
|
|
135
|
+
outcomeParams?: string
|
|
136
|
+
|
|
137
|
+
@Field(() => GraphQLJSON, { nullable: true, description: 'Values of the outcome code.' })
|
|
138
|
+
get outcomeParamsJson(): any {
|
|
139
|
+
return parsed(this.outcomeParams)
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
/** The earlier attempts' endings, `[{ attempt, status, outcomeCode, startedAt, finishedAt }]`, as JSON. */
|
|
143
|
+
@Column({ type: 'text', nullable: true })
|
|
144
|
+
attempts?: string
|
|
145
|
+
|
|
146
|
+
@Field(() => GraphQLJSON, { nullable: true, description: 'How the earlier attempts ended.' })
|
|
147
|
+
get attemptsJson(): any {
|
|
148
|
+
return parsed(this.attempts)
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
@Column({ type: 'varchar', length: 1024, nullable: true })
|
|
152
|
+
groundingWarnings?: string
|
|
153
|
+
|
|
154
|
+
@Column({ nullable: true })
|
|
155
|
+
@Field({ nullable: true, description: 'The AI agent that answered.' })
|
|
156
|
+
agentId?: string
|
|
157
|
+
|
|
158
|
+
@Column({ nullable: true })
|
|
159
|
+
@Field({ nullable: true, description: 'provider:model.' })
|
|
160
|
+
clientId?: string
|
|
161
|
+
|
|
162
|
+
@Column({ type: 'int', default: 0 })
|
|
163
|
+
@Field(type => Int, { description: 'Model requests of this attempt.' })
|
|
164
|
+
modelCalls: number
|
|
165
|
+
|
|
166
|
+
@Column({ type: 'int', default: 0 })
|
|
167
|
+
@Field(type => Int, { description: 'Input tokens of this attempt.' })
|
|
168
|
+
inputTokens: number
|
|
169
|
+
|
|
170
|
+
@Column({ type: 'int', default: 0 })
|
|
171
|
+
@Field(type => Int, { description: 'Output tokens of this attempt.' })
|
|
172
|
+
outputTokens: number
|
|
173
|
+
|
|
174
|
+
@Column({ type: 'int', default: 0 })
|
|
175
|
+
@Field(type => Int, { description: 'Cards a read offered that were not kept - there was no person to press them.' })
|
|
176
|
+
cardsDropped: number
|
|
177
|
+
|
|
178
|
+
@ManyToOne(type => User, { nullable: true })
|
|
179
|
+
@Field(type => User, { nullable: true, description: 'Creator.' })
|
|
180
|
+
creator?: User
|
|
181
|
+
|
|
182
|
+
@RelationId((run: AssistantRun) => run.creator)
|
|
183
|
+
creatorId?: string
|
|
184
|
+
|
|
185
|
+
@ManyToOne(type => User, { nullable: true })
|
|
186
|
+
@Field(type => User, { nullable: true, description: 'Last updater.' })
|
|
187
|
+
updater?: User
|
|
188
|
+
|
|
189
|
+
@RelationId((run: AssistantRun) => run.updater)
|
|
190
|
+
updaterId?: string
|
|
191
|
+
|
|
192
|
+
@CreateDateColumn()
|
|
193
|
+
@Field({ nullable: true, description: 'Creation timestamp.' })
|
|
194
|
+
createdAt?: Date
|
|
195
|
+
|
|
196
|
+
@UpdateDateColumn()
|
|
197
|
+
@Field({ nullable: true, description: 'Last update timestamp.' })
|
|
198
|
+
updatedAt?: Date
|
|
199
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
import { AssistantRun } from './assistant-run.js'
|
|
2
|
+
|
|
3
|
+
export { AssistantRun }
|
|
4
|
+
export { runAssistantUnattended, findAssistantRun } from './run-unattended.js'
|
|
5
|
+
export type { UnattendedRunInput, UnattendedRunDeps } from './run-unattended.js'
|
|
6
|
+
export { UNATTENDED_DEFAULTS } from './limits.js'
|
|
7
|
+
export { assistantRunOutcomeText, ASSISTANT_RUN_OUTCOME_ENGLISH } from './run-outcome.js'
|
|
8
|
+
export type { AssistantRunStatus } from './run-ledger.js'
|
|
9
|
+
|
|
10
|
+
export const entities = [AssistantRun]
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/* Pure. What one unattended run of a mode may spend (design/ai/unattended-run.md §4). */
|
|
2
|
+
import type { UnattendedLimits } from '../assistant-mode/types.js'
|
|
3
|
+
|
|
4
|
+
export const UNATTENDED_DEFAULTS: Required<UnattendedLimits> = {
|
|
5
|
+
maxModelCalls: 8,
|
|
6
|
+
maxTokens: 60000,
|
|
7
|
+
timeoutMs: 120000
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* How long past its time limit a running attempt is still taken as alive. A server that died mid-run leaves the row
|
|
12
|
+
* `running`; past this the next call takes the occurrence over (§5).
|
|
13
|
+
*/
|
|
14
|
+
export const ABANDON_MARGIN_MS = 30000
|
|
15
|
+
|
|
16
|
+
/** The mode's limits, each absent or unusable one at its default. */
|
|
17
|
+
export function unattendedLimits(declared: UnattendedLimits | undefined): Required<UnattendedLimits> {
|
|
18
|
+
const pick = (value: unknown, fallback: number) => (typeof value === 'number' && Number.isFinite(value) && value > 0 ? value : fallback)
|
|
19
|
+
return {
|
|
20
|
+
maxModelCalls: Math.floor(pick(declared?.maxModelCalls, UNATTENDED_DEFAULTS.maxModelCalls)),
|
|
21
|
+
maxTokens: pick(declared?.maxTokens, UNATTENDED_DEFAULTS.maxTokens),
|
|
22
|
+
timeoutMs: pick(declared?.timeoutMs, UNATTENDED_DEFAULTS.timeoutMs)
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Who the run is - judged again at every run (design/ai/unattended-run.md §2, ADR-0142 §8.1).
|
|
3
|
+
*
|
|
4
|
+
* The run acts for the policy owner and for no one else. There is no second user to fall back to: a refused owner
|
|
5
|
+
* means a refused run, never one under an administrator.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
/** The same door a person passes to ask the assistant (`assistantChat`). */
|
|
9
|
+
export const ASK_ASSISTANT_GATE = { category: 'ai-assistant', privilege: 'mutation', owner: true }
|
|
10
|
+
|
|
11
|
+
export type RunAsRefusal =
|
|
12
|
+
| 'assistant-run-as-unknown'
|
|
13
|
+
| 'assistant-run-as-inactive'
|
|
14
|
+
| 'assistant-run-as-not-member'
|
|
15
|
+
| 'assistant-run-as-not-permitted'
|
|
16
|
+
|
|
17
|
+
export interface RunAsChecks<TUser, TDomain> {
|
|
18
|
+
/** The user as stored now - not as the caller last saw them. */
|
|
19
|
+
findUser: (id: string) => Promise<TUser | undefined | null>
|
|
20
|
+
/** Whether they can enter this domain - auth-base `canUserCheckinDomain`. */
|
|
21
|
+
mayCheckin: (user: TUser, domain: TDomain) => Promise<boolean>
|
|
22
|
+
/** auth-base `checkPermission`, with no address and no prohibitions: there is no request. */
|
|
23
|
+
permits: (gate: typeof ASK_ASSISTANT_GATE, user: TUser, domain: TDomain) => Promise<boolean>
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/** `user` is the account as `findUser` read it just now - absent when there is none. */
|
|
27
|
+
export async function judgeRunAs<TUser extends { id?: string; status?: string }, TDomain>(
|
|
28
|
+
user: TUser | undefined | null,
|
|
29
|
+
domain: TDomain,
|
|
30
|
+
checks: Omit<RunAsChecks<TUser, TDomain>, 'findUser'>
|
|
31
|
+
): Promise<{ user: TUser } | { refused: RunAsRefusal }> {
|
|
32
|
+
if (!user) return { refused: 'assistant-run-as-unknown' }
|
|
33
|
+
/* Only an account that can sign in now. Locked, banned, deleted, or one waiting for a password reset is not. */
|
|
34
|
+
if (user.status !== 'activated') return { refused: 'assistant-run-as-inactive' }
|
|
35
|
+
if (!(await checks.mayCheckin(user, domain))) return { refused: 'assistant-run-as-not-member' }
|
|
36
|
+
if (!(await checks.permits(ASK_ASSISTANT_GATE, user, domain))) return { refused: 'assistant-run-as-not-permitted' }
|
|
37
|
+
return { user }
|
|
38
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* The client one unattended run talks through - it counts what the run spends and refuses the request past the limit
|
|
3
|
+
* (design/ai/unattended-run.md §4).
|
|
4
|
+
*
|
|
5
|
+
* It wraps the domain's metered client, so every request still writes its AiUsage row at the provider boundary
|
|
6
|
+
* (ADR-0061 decision 6); this only adds the run's own ceiling.
|
|
7
|
+
*/
|
|
8
|
+
import type { AgentClient } from '@things-factory/ai-client-base'
|
|
9
|
+
|
|
10
|
+
export interface RunSpend {
|
|
11
|
+
modelCalls: number
|
|
12
|
+
inputTokens: number
|
|
13
|
+
outputTokens: number
|
|
14
|
+
/** Set once a request was refused, with the limit it hit. */
|
|
15
|
+
exceeded?: { limit: 'model-calls' | 'tokens'; max: number; used: number }
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
export class RunBudgetExceeded extends Error {
|
|
19
|
+
readonly code = 'assistant-run-budget-exceeded'
|
|
20
|
+
constructor(readonly params: NonNullable<RunSpend['exceeded']>) {
|
|
21
|
+
super(`the run reached its ${params.limit} limit (${params.used} of ${params.max})`)
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function budgetedClient(client: AgentClient, limits: { maxModelCalls: number; maxTokens: number }): { client: AgentClient; spend: RunSpend } {
|
|
26
|
+
const spend: RunSpend = { modelCalls: 0, inputTokens: 0, outputTokens: 0 }
|
|
27
|
+
|
|
28
|
+
const admit = () => {
|
|
29
|
+
if (spend.modelCalls >= limits.maxModelCalls) {
|
|
30
|
+
spend.exceeded ??= { limit: 'model-calls', max: limits.maxModelCalls, used: spend.modelCalls }
|
|
31
|
+
} else if (spend.inputTokens + spend.outputTokens >= limits.maxTokens) {
|
|
32
|
+
spend.exceeded ??= { limit: 'tokens', max: limits.maxTokens, used: spend.inputTokens + spend.outputTokens }
|
|
33
|
+
}
|
|
34
|
+
if (spend.exceeded) throw new RunBudgetExceeded(spend.exceeded)
|
|
35
|
+
spend.modelCalls++
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
const counted = (options: any) => ({
|
|
39
|
+
...options,
|
|
40
|
+
onUsage: (usage: any) => {
|
|
41
|
+
spend.inputTokens += Number(usage?.inputTokens) || 0
|
|
42
|
+
spend.outputTokens += Number(usage?.outputTokens) || 0
|
|
43
|
+
options?.onUsage?.(usage)
|
|
44
|
+
}
|
|
45
|
+
})
|
|
46
|
+
|
|
47
|
+
const wrapped: AgentClient = {
|
|
48
|
+
id: client.id,
|
|
49
|
+
agent: client.agent,
|
|
50
|
+
chat: (messages, options) => (admit(), client.chat(messages, counted(options))),
|
|
51
|
+
generateJSON: (prompt, options) => (admit(), client.generateJSON(prompt, counted(options))),
|
|
52
|
+
...(client.chatWithTools && {
|
|
53
|
+
chatWithTools: (messages, tools, options) => (admit(), client.chatWithTools!(messages, tools, counted(options)))
|
|
54
|
+
}),
|
|
55
|
+
listModels: () => client.listModels()
|
|
56
|
+
} as AgentClient
|
|
57
|
+
return { client: wrapped, spend }
|
|
58
|
+
}
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Pure. What a call for an occurrence that already has a row does (design/ai/unattended-run.md §5).
|
|
3
|
+
*
|
|
4
|
+
* One row is one occurrence - (domain, source, subject, scheduledAt). scheduler-client calls again when an answer came
|
|
5
|
+
* late or failed; a second call must not run the model a second time while the first is alive or after it succeeded.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
export type AssistantRunStatus = 'running' | 'succeeded' | 'failed' | 'refused'
|
|
9
|
+
|
|
10
|
+
export interface LedgerRow {
|
|
11
|
+
status: AssistantRunStatus | string
|
|
12
|
+
deadlineAt?: Date | string | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* `return` - hand the stored row back, run nothing. `retake` - start the next attempt on the same row.
|
|
17
|
+
*
|
|
18
|
+
* A succeeded run is the answer. A running one is left alone until its deadline; past it the server that held it is
|
|
19
|
+
* taken as gone. A failed or refused one runs again - a retry is for exactly that, and how many retries there are is
|
|
20
|
+
* the caller's limit (scheduler-client `max_retry_count`), not this one's.
|
|
21
|
+
*/
|
|
22
|
+
export function nextStep(row: LedgerRow, now: Date): 'return' | 'retake' {
|
|
23
|
+
if (row.status === 'succeeded') return 'return'
|
|
24
|
+
if (row.status === 'running') {
|
|
25
|
+
const deadline = row.deadlineAt ? new Date(row.deadlineAt).valueOf() : NaN
|
|
26
|
+
/* A running row with no readable deadline is not taken over - two runs at once is the one thing this prevents. */
|
|
27
|
+
if (!Number.isFinite(deadline)) return 'return'
|
|
28
|
+
return deadline > now.valueOf() ? 'return' : 'retake'
|
|
29
|
+
}
|
|
30
|
+
return 'retake'
|
|
31
|
+
}
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
/*
|
|
2
|
+
* Why a run failed or was refused, as a person reads it (design/ai/unattended-run.md §7).
|
|
3
|
+
*
|
|
4
|
+
* The run keeps a code and its values (ADR-0054); the sentence is made where it is shown, under this package's key
|
|
5
|
+
* `ai-assistant.run.<code>` - an outcome is a stored value, not a thrown refusal. The English below is the sentence
|
|
6
|
+
* when no translation has the key; the application that shows a run does not write its own.
|
|
7
|
+
*/
|
|
8
|
+
|
|
9
|
+
export const outcomeKey = (code: string) => `ai-assistant.run.${code}`
|
|
10
|
+
|
|
11
|
+
export const ASSISTANT_RUN_OUTCOME_ENGLISH: Record<string, string> = {
|
|
12
|
+
'assistant-run-as-unknown': 'the account the run was to act as does not exist.',
|
|
13
|
+
'assistant-run-as-inactive': "the owner's account is not active, so the run did not start.",
|
|
14
|
+
'assistant-run-as-not-member': 'the owner can no longer enter this domain, so the run did not start.',
|
|
15
|
+
'assistant-run-as-not-permitted': 'the owner may no longer ask the AI assistant, so the run did not start.',
|
|
16
|
+
'assistant-mode-not-unattended': 'the assistant mode {kind} does not run without a person.',
|
|
17
|
+
'unknown-assistant-mode': 'this server has no assistant mode {kind}.',
|
|
18
|
+
'assistant-run-budget-exceeded': 'the run reached its {limit} limit ({used} of {max}) and stopped.',
|
|
19
|
+
'assistant-run-step-limit': 'the run did not finish within {steps} steps.',
|
|
20
|
+
'assistant-run-timeout': 'the run did not finish within {timeoutMs} ms.',
|
|
21
|
+
'assistant-run-empty-draft': 'the run ended without writing a draft.',
|
|
22
|
+
'assistant-run-model-error': 'the AI model failed during the run.',
|
|
23
|
+
'assistant-run-abandoned': 'the run stopped partway and was started again.',
|
|
24
|
+
'ai-not-configured': 'AI is not set up for this domain.',
|
|
25
|
+
'ai-agent-not-available': 'the AI agent picked for this run is not available here.'
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
type Translate = (key: string, options?: Record<string, unknown>) => string
|
|
29
|
+
|
|
30
|
+
/** The sentence for a run's outcome, or undefined when it has none (running, or succeeded). */
|
|
31
|
+
export function assistantRunOutcomeText(run: { outcomeCode?: string | null; outcomeParams?: string | null }, t?: Translate): string | undefined {
|
|
32
|
+
const code = run?.outcomeCode
|
|
33
|
+
if (!code) return undefined
|
|
34
|
+
let params: Record<string, unknown> = {}
|
|
35
|
+
try {
|
|
36
|
+
params = run.outcomeParams ? JSON.parse(run.outcomeParams) : {}
|
|
37
|
+
} catch {
|
|
38
|
+
params = {}
|
|
39
|
+
}
|
|
40
|
+
const english = ASSISTANT_RUN_OUTCOME_ENGLISH[code] ?? code
|
|
41
|
+
const fill = (text: string) => text.replace(/\{(\w+)\}/g, (_, name) => (params[name] === undefined ? `{${name}}` : String(params[name])))
|
|
42
|
+
return t ? t(outcomeKey(code), { ...params, defaultValue: english }) : fill(english)
|
|
43
|
+
}
|
|
@@ -0,0 +1,83 @@
|
|
|
1
|
+
import { join } from 'path'
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Two calls for one occurrence at the same moment, on postgres (operato-application/design/ai/unattended-run.md §5).
|
|
5
|
+
*
|
|
6
|
+
* sqlite holds one connection and cannot run two transactions side by side, so what it says about this is no
|
|
7
|
+
* evidence either way. This needs a real server, and runs only when AI_ASSISTANT_PG_URL names one:
|
|
8
|
+
*
|
|
9
|
+
* docker run -d --name ai-run-pg -e POSTGRES_PASSWORD=airun -e POSTGRES_USER=airun -e POSTGRES_DB=airun \
|
|
10
|
+
* -p 55434:5432 postgres:14-alpine
|
|
11
|
+
* AI_ASSISTANT_PG_URL=postgres://airun:airun@localhost:55434/airun npx jest --runInBand \
|
|
12
|
+
* packages/ai-assistant/server/service/unattended-run/run-store-race.pg.test.ts
|
|
13
|
+
*
|
|
14
|
+
* It reads `dist-server` - build ai-assistant first.
|
|
15
|
+
*
|
|
16
|
+
* Each round must end with exactly one of the two holding the attempt: two first calls make one row, and two calls
|
|
17
|
+
* taking over one failed occurrence make one next attempt. Both holding it is the defect - the model runs twice.
|
|
18
|
+
*/
|
|
19
|
+
|
|
20
|
+
const url = process.env.AI_ASSISTANT_PG_URL
|
|
21
|
+
const maybe = url ? describe : describe.skip
|
|
22
|
+
|
|
23
|
+
maybe('two calls for one occurrence at once, on postgres', () => {
|
|
24
|
+
const { DataSource } = require('typeorm')
|
|
25
|
+
const { entityClosure } = require(join(__dirname, '..', '..', '..', '..', 'ops-master', 'test', 'support', 'database.cjs'))
|
|
26
|
+
const { NamingStrategy } = require('@things-factory/shell/dist-server/initializers/naming-strategy.js')
|
|
27
|
+
const DIST = join(__dirname, '..', '..', '..', 'dist-server', 'service', 'unattended-run')
|
|
28
|
+
const { AssistantRun } = require(join(DIST, 'assistant-run.js'))
|
|
29
|
+
const { typeormRunStore } = require(join(DIST, 'run-store.js'))
|
|
30
|
+
const { Domain } = require('@things-factory/shell')
|
|
31
|
+
const { User } = require('@things-factory/auth-base')
|
|
32
|
+
|
|
33
|
+
let db: any
|
|
34
|
+
let domain: any
|
|
35
|
+
let owner: any
|
|
36
|
+
|
|
37
|
+
beforeAll(async () => {
|
|
38
|
+
db = new DataSource({
|
|
39
|
+
type: 'postgres',
|
|
40
|
+
url,
|
|
41
|
+
synchronize: true,
|
|
42
|
+
dropSchema: true,
|
|
43
|
+
entities: entityClosure([AssistantRun]),
|
|
44
|
+
namingStrategy: new NamingStrategy()
|
|
45
|
+
})
|
|
46
|
+
await db.initialize()
|
|
47
|
+
domain = await db.getRepository(Domain).save({ name: 'd', subdomain: 'd' })
|
|
48
|
+
owner = await db.getRepository(User).save({ name: 'o', username: 'o', email: 'o@example.com', status: 'activated' })
|
|
49
|
+
}, 60000)
|
|
50
|
+
|
|
51
|
+
afterAll(async () => db?.destroy())
|
|
52
|
+
|
|
53
|
+
/* Each call on its own connection, as two servers would be. */
|
|
54
|
+
const store = () => typeormRunStore(() => db.getRepository(AssistantRun))
|
|
55
|
+
const start = (at: number) => ({ name: 'race', modeKind: 'office.strategy-review', runAsId: owner.id, startedAt: new Date(at), deadlineAt: new Date(at + 1000) })
|
|
56
|
+
|
|
57
|
+
it('★ two first calls make one row, and only one of them holds it', async () => {
|
|
58
|
+
for (let round = 0; round < 25; round++) {
|
|
59
|
+
const identity = { domainId: domain.id, source: 'office.strategy-review', subject: 'p', scheduledAt: new Date(Date.UTC(2026, 0, 1) + round * 60000) }
|
|
60
|
+
const now = Date.now()
|
|
61
|
+
const [a, b] = await Promise.all([store().claim(identity, start(now), new Date(now)), store().claim(identity, start(now), new Date(now))])
|
|
62
|
+
const rows = await db.getRepository(AssistantRun).count({ where: { subject: 'p', scheduledAt: identity.scheduledAt } })
|
|
63
|
+
expect(rows).toBe(1)
|
|
64
|
+
expect([a.claimed, b.claimed].filter(Boolean)).toHaveLength(1)
|
|
65
|
+
expect(a.row.id).toBe(b.row.id)
|
|
66
|
+
}
|
|
67
|
+
}, 60000)
|
|
68
|
+
|
|
69
|
+
it('★ two calls taking over one failed occurrence make one next attempt', async () => {
|
|
70
|
+
for (let round = 0; round < 25; round++) {
|
|
71
|
+
const identity = { domainId: domain.id, source: 'office.strategy-review', subject: 'q', scheduledAt: new Date(Date.UTC(2026, 1, 1) + round * 60000) }
|
|
72
|
+
const now = Date.now()
|
|
73
|
+
const first = await store().claim(identity, start(now), new Date(now))
|
|
74
|
+
await store().finish({ id: first.row.id, attempt: 1 }, { status: 'failed', outcomeCode: 'assistant-run-model-error', finishedAt: new Date(now) })
|
|
75
|
+
|
|
76
|
+
const [a, b] = await Promise.all([store().claim(identity, start(now + 10), new Date(now + 10)), store().claim(identity, start(now + 10), new Date(now + 10))])
|
|
77
|
+
const row = await db.getRepository(AssistantRun).findOne({ where: { id: first.row.id } })
|
|
78
|
+
expect([a.claimed, b.claimed].filter(Boolean)).toHaveLength(1)
|
|
79
|
+
expect(row).toMatchObject({ status: 'running', attempt: 2 })
|
|
80
|
+
expect(JSON.parse(row.attempts)).toHaveLength(1)
|
|
81
|
+
}
|
|
82
|
+
}, 60000)
|
|
83
|
+
})
|