@orthacms/copilot-server 0.0.0-reserve.0

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 (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/index.d.ts +24 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +48 -0
  6. package/dist/lib/chat/application/appliers-registrar.d.ts +16 -0
  7. package/dist/lib/chat/application/appliers-registrar.d.ts.map +1 -0
  8. package/dist/lib/chat/application/appliers-registrar.js +50 -0
  9. package/dist/lib/chat/application/capability-profile.service.d.ts +43 -0
  10. package/dist/lib/chat/application/capability-profile.service.d.ts.map +1 -0
  11. package/dist/lib/chat/application/capability-profile.service.js +75 -0
  12. package/dist/lib/chat/application/content-type-summary.service.d.ts +32 -0
  13. package/dist/lib/chat/application/content-type-summary.service.d.ts.map +1 -0
  14. package/dist/lib/chat/application/content-type-summary.service.js +64 -0
  15. package/dist/lib/chat/application/decide-proposal.service.d.ts +80 -0
  16. package/dist/lib/chat/application/decide-proposal.service.d.ts.map +1 -0
  17. package/dist/lib/chat/application/decide-proposal.service.js +112 -0
  18. package/dist/lib/chat/application/dto/create-run.dto.d.ts +138 -0
  19. package/dist/lib/chat/application/dto/create-run.dto.d.ts.map +1 -0
  20. package/dist/lib/chat/application/dto/create-run.dto.js +253 -0
  21. package/dist/lib/chat/application/dto/decide-tool-permission.dto.d.ts +20 -0
  22. package/dist/lib/chat/application/dto/decide-tool-permission.dto.d.ts.map +1 -0
  23. package/dist/lib/chat/application/dto/decide-tool-permission.dto.js +44 -0
  24. package/dist/lib/chat/application/dto/extend-tool-permission.dto.d.ts +18 -0
  25. package/dist/lib/chat/application/dto/extend-tool-permission.dto.d.ts.map +1 -0
  26. package/dist/lib/chat/application/dto/extend-tool-permission.dto.js +33 -0
  27. package/dist/lib/chat/application/dto/list-conversations-query.dto.d.ts +18 -0
  28. package/dist/lib/chat/application/dto/list-conversations-query.dto.d.ts.map +1 -0
  29. package/dist/lib/chat/application/dto/list-conversations-query.dto.js +39 -0
  30. package/dist/lib/chat/application/dto/list-proposals-query.dto.d.ts +15 -0
  31. package/dist/lib/chat/application/dto/list-proposals-query.dto.d.ts.map +1 -0
  32. package/dist/lib/chat/application/dto/list-proposals-query.dto.js +41 -0
  33. package/dist/lib/chat/application/dto/update-conversation.dto.d.ts +68 -0
  34. package/dist/lib/chat/application/dto/update-conversation.dto.d.ts.map +1 -0
  35. package/dist/lib/chat/application/dto/update-conversation.dto.js +115 -0
  36. package/dist/lib/chat/application/proposal-applier.registry.d.ts +27 -0
  37. package/dist/lib/chat/application/proposal-applier.registry.d.ts.map +1 -0
  38. package/dist/lib/chat/application/proposal-applier.registry.js +62 -0
  39. package/dist/lib/chat/application/run-engine.service.d.ts +243 -0
  40. package/dist/lib/chat/application/run-engine.service.d.ts.map +1 -0
  41. package/dist/lib/chat/application/run-engine.service.js +1058 -0
  42. package/dist/lib/chat/application/summarize-tool-output.d.ts +15 -0
  43. package/dist/lib/chat/application/summarize-tool-output.d.ts.map +1 -0
  44. package/dist/lib/chat/application/summarize-tool-output.js +61 -0
  45. package/dist/lib/chat/application/system-prompt.d.ts +81 -0
  46. package/dist/lib/chat/application/system-prompt.d.ts.map +1 -0
  47. package/dist/lib/chat/application/system-prompt.js +477 -0
  48. package/dist/lib/chat/application/tool-permission.broker.d.ts +118 -0
  49. package/dist/lib/chat/application/tool-permission.broker.d.ts.map +1 -0
  50. package/dist/lib/chat/application/tool-permission.broker.js +221 -0
  51. package/dist/lib/chat/http/controllers/create-run.controller.d.ts +30 -0
  52. package/dist/lib/chat/http/controllers/create-run.controller.d.ts.map +1 -0
  53. package/dist/lib/chat/http/controllers/create-run.controller.js +159 -0
  54. package/dist/lib/chat/http/controllers/get-conversation.controller.d.ts +22 -0
  55. package/dist/lib/chat/http/controllers/get-conversation.controller.d.ts.map +1 -0
  56. package/dist/lib/chat/http/controllers/get-conversation.controller.js +50 -0
  57. package/dist/lib/chat/http/controllers/list-conversations.controller.d.ts +20 -0
  58. package/dist/lib/chat/http/controllers/list-conversations.controller.d.ts.map +1 -0
  59. package/dist/lib/chat/http/controllers/list-conversations.controller.js +47 -0
  60. package/dist/lib/chat/http/controllers/list-models.controller.d.ts +35 -0
  61. package/dist/lib/chat/http/controllers/list-models.controller.d.ts.map +1 -0
  62. package/dist/lib/chat/http/controllers/list-models.controller.js +48 -0
  63. package/dist/lib/chat/http/controllers/proposals.controller.d.ts +43 -0
  64. package/dist/lib/chat/http/controllers/proposals.controller.d.ts.map +1 -0
  65. package/dist/lib/chat/http/controllers/proposals.controller.js +98 -0
  66. package/dist/lib/chat/http/controllers/tool-permission.controller.d.ts +57 -0
  67. package/dist/lib/chat/http/controllers/tool-permission.controller.d.ts.map +1 -0
  68. package/dist/lib/chat/http/controllers/tool-permission.controller.js +116 -0
  69. package/dist/lib/chat/http/controllers/update-conversation.controller.d.ts +37 -0
  70. package/dist/lib/chat/http/controllers/update-conversation.controller.d.ts.map +1 -0
  71. package/dist/lib/chat/http/controllers/update-conversation.controller.js +108 -0
  72. package/dist/lib/chat/http/sse-stream.d.ts +41 -0
  73. package/dist/lib/chat/http/sse-stream.d.ts.map +1 -0
  74. package/dist/lib/chat/http/sse-stream.js +95 -0
  75. package/dist/lib/chat/infrastructure/persistence/conversation.repository.d.ts +159 -0
  76. package/dist/lib/chat/infrastructure/persistence/conversation.repository.d.ts.map +1 -0
  77. package/dist/lib/chat/infrastructure/persistence/conversation.repository.js +249 -0
  78. package/dist/lib/chat/infrastructure/persistence/derive-title.d.ts +13 -0
  79. package/dist/lib/chat/infrastructure/persistence/derive-title.d.ts.map +1 -0
  80. package/dist/lib/chat/infrastructure/persistence/derive-title.js +35 -0
  81. package/dist/lib/chat/infrastructure/persistence/proposal.repository.d.ts +86 -0
  82. package/dist/lib/chat/infrastructure/persistence/proposal.repository.d.ts.map +1 -0
  83. package/dist/lib/chat/infrastructure/persistence/proposal.repository.js +152 -0
  84. package/dist/lib/chat/infrastructure/schema/conversations.d.ts +189 -0
  85. package/dist/lib/chat/infrastructure/schema/conversations.d.ts.map +1 -0
  86. package/dist/lib/chat/infrastructure/schema/conversations.js +100 -0
  87. package/dist/lib/chat/infrastructure/schema/external-refs.d.ts +67 -0
  88. package/dist/lib/chat/infrastructure/schema/external-refs.d.ts.map +1 -0
  89. package/dist/lib/chat/infrastructure/schema/external-refs.js +28 -0
  90. package/dist/lib/chat/infrastructure/schema/index.d.ts +5 -0
  91. package/dist/lib/chat/infrastructure/schema/index.d.ts.map +1 -0
  92. package/dist/lib/chat/infrastructure/schema/index.js +12 -0
  93. package/dist/lib/chat/infrastructure/schema/messages.d.ts +264 -0
  94. package/dist/lib/chat/infrastructure/schema/messages.d.ts.map +1 -0
  95. package/dist/lib/chat/infrastructure/schema/messages.js +96 -0
  96. package/dist/lib/chat/infrastructure/schema/proposals.d.ts +328 -0
  97. package/dist/lib/chat/infrastructure/schema/proposals.d.ts.map +1 -0
  98. package/dist/lib/chat/infrastructure/schema/proposals.js +87 -0
  99. package/dist/lib/chat/infrastructure/schema/tool-calls.d.ts +203 -0
  100. package/dist/lib/chat/infrastructure/schema/tool-calls.d.ts.map +1 -0
  101. package/dist/lib/chat/infrastructure/schema/tool-calls.js +50 -0
  102. package/dist/lib/copilot.module.d.ts +30 -0
  103. package/dist/lib/copilot.module.d.ts.map +1 -0
  104. package/dist/lib/copilot.module.js +146 -0
  105. package/dist/lib/copilot.tokens.d.ts +34 -0
  106. package/dist/lib/copilot.tokens.d.ts.map +1 -0
  107. package/dist/lib/copilot.tokens.js +38 -0
  108. package/dist/lib/infrastructure/model-registry.d.ts +22 -0
  109. package/dist/lib/infrastructure/model-registry.d.ts.map +1 -0
  110. package/dist/lib/infrastructure/model-registry.js +54 -0
  111. package/dist/lib/skills/application/dto/create-skill.dto.d.ts +36 -0
  112. package/dist/lib/skills/application/dto/create-skill.dto.d.ts.map +1 -0
  113. package/dist/lib/skills/application/dto/create-skill.dto.js +106 -0
  114. package/dist/lib/skills/application/dto/update-skill.dto.d.ts +28 -0
  115. package/dist/lib/skills/application/dto/update-skill.dto.d.ts.map +1 -0
  116. package/dist/lib/skills/application/dto/update-skill.dto.js +96 -0
  117. package/dist/lib/skills/application/skill-catalog.service.d.ts +97 -0
  118. package/dist/lib/skills/application/skill-catalog.service.d.ts.map +1 -0
  119. package/dist/lib/skills/application/skill-catalog.service.js +157 -0
  120. package/dist/lib/skills/http/controllers/list-skills.controller.d.ts +19 -0
  121. package/dist/lib/skills/http/controllers/list-skills.controller.d.ts.map +1 -0
  122. package/dist/lib/skills/http/controllers/list-skills.controller.js +50 -0
  123. package/dist/lib/skills/http/controllers/manage-skills.controller.d.ts +64 -0
  124. package/dist/lib/skills/http/controllers/manage-skills.controller.d.ts.map +1 -0
  125. package/dist/lib/skills/http/controllers/manage-skills.controller.js +182 -0
  126. package/dist/lib/skills/infrastructure/persistence/skill.repository.d.ts +78 -0
  127. package/dist/lib/skills/infrastructure/persistence/skill.repository.d.ts.map +1 -0
  128. package/dist/lib/skills/infrastructure/persistence/skill.repository.js +116 -0
  129. package/dist/lib/skills/infrastructure/schema/index.d.ts +2 -0
  130. package/dist/lib/skills/infrastructure/schema/index.d.ts.map +1 -0
  131. package/dist/lib/skills/infrastructure/schema/index.js +6 -0
  132. package/dist/lib/skills/infrastructure/schema/skills.d.ts +212 -0
  133. package/dist/lib/skills/infrastructure/schema/skills.d.ts.map +1 -0
  134. package/dist/lib/skills/infrastructure/schema/skills.js +78 -0
  135. package/dist/lib/types/copilot-config.d.ts +55 -0
  136. package/dist/lib/types/copilot-config.d.ts.map +1 -0
  137. package/dist/lib/types/copilot-config.js +2 -0
  138. package/dist/lib/utils/copilot-plugin.d.ts +66 -0
  139. package/dist/lib/utils/copilot-plugin.d.ts.map +1 -0
  140. package/dist/lib/utils/copilot-plugin.js +87 -0
  141. package/migrations/0000_copilot_chat.sql +48 -0
  142. package/migrations/0001_proposals.sql +34 -0
  143. package/migrations/0002_drop_workspace_policies.sql +1 -0
  144. package/migrations/0003_conversation_allowed_tools.sql +1 -0
  145. package/migrations/0004_message_attachments.sql +1 -0
  146. package/migrations/0005_skills.sql +19 -0
  147. package/migrations/0006_conversation_model_choice.sql +1 -0
  148. package/migrations/meta/0000_snapshot.json +390 -0
  149. package/migrations/meta/0001_snapshot.json +657 -0
  150. package/migrations/meta/0002_snapshot.json +610 -0
  151. package/migrations/meta/0003_snapshot.json +617 -0
  152. package/migrations/meta/0004_snapshot.json +623 -0
  153. package/migrations/meta/0005_snapshot.json +771 -0
  154. package/migrations/meta/0006_snapshot.json +777 -0
  155. package/migrations/meta/_journal.json +55 -0
  156. package/package.json +46 -0
@@ -0,0 +1,1058 @@
1
+ "use strict";
2
+ var RunEngine_1;
3
+ Object.defineProperty(exports, "__esModule", { value: true });
4
+ exports.ENGINE_PROMPT_VERSION = exports.AttachmentError = exports.RunEngine = exports.CopilotDisabledError = exports.UnknownModelChoiceError = void 0;
5
+ const tslib_1 = require("tslib");
6
+ const common_1 = require("@nestjs/common");
7
+ const node_crypto_1 = require("node:crypto");
8
+ const copilot_domain_1 = require("@orthacms/copilot-domain");
9
+ const tools_server_1 = require("@orthacms/tools-server");
10
+ const copilot_tokens_1 = require("../../copilot.tokens");
11
+ const conversation_repository_1 = require("../infrastructure/persistence/conversation.repository");
12
+ const proposal_repository_1 = require("../infrastructure/persistence/proposal.repository");
13
+ const capability_profile_service_1 = require("./capability-profile.service");
14
+ const decide_proposal_service_1 = require("./decide-proposal.service");
15
+ const tool_permission_broker_1 = require("./tool-permission.broker");
16
+ const skill_catalog_service_1 = require("../../skills/application/skill-catalog.service");
17
+ const summarize_tool_output_1 = require("./summarize-tool-output");
18
+ const system_prompt_1 = require("./system-prompt");
19
+ /**
20
+ * Thrown when a run names a provider or model that isn't registered. The
21
+ * controller turns it into an error frame rather than a 500 — it is a bad
22
+ * request, and by the time we know, the stream is usually already open.
23
+ */
24
+ class UnknownModelChoiceError extends Error {
25
+ constructor(message) {
26
+ super(message);
27
+ this.name = 'UnknownModelChoiceError';
28
+ }
29
+ }
30
+ exports.UnknownModelChoiceError = UnknownModelChoiceError;
31
+ /** Thrown when a run cannot start at all — the controller maps it to a 4xx. */
32
+ class CopilotDisabledError extends Error {
33
+ constructor() {
34
+ super('The copilot is disabled.');
35
+ this.name = 'CopilotDisabledError';
36
+ }
37
+ }
38
+ exports.CopilotDisabledError = CopilotDisabledError;
39
+ /**
40
+ * The run engine: a **bounded** loop that calls the model, executes the tools
41
+ * it asks for, feeds the results back, and stops on a final answer or a ceiling
42
+ * ([`docs/design/copilot.md`](../../../../../../docs/design/copilot.md) §5).
43
+ *
44
+ * It is an async generator rather than a service that writes to a response.
45
+ * That keeps the transport out of the engine — the SSE controller serializes
46
+ * whatever this yields — and it is what makes the loop testable without a
47
+ * socket: a test drains the generator and asserts on the event sequence.
48
+ */
49
+ let RunEngine = RunEngine_1 = class RunEngine {
50
+ registry;
51
+ resolver;
52
+ config;
53
+ profiles;
54
+ tools;
55
+ conversations;
56
+ proposals;
57
+ decisions;
58
+ permissions;
59
+ skills;
60
+ attachments;
61
+ logger = new common_1.Logger(RunEngine_1.name);
62
+ /** The ceilings this engine enforces. */
63
+ limits;
64
+ constructor(registry, resolver, config, profiles, tools, conversations, proposals, decisions, permissions, skills, attachments = null, limits = null) {
65
+ this.registry = registry;
66
+ this.resolver = resolver;
67
+ this.config = config;
68
+ this.profiles = profiles;
69
+ this.tools = tools;
70
+ this.conversations = conversations;
71
+ this.proposals = proposals;
72
+ this.decisions = decisions;
73
+ this.permissions = permissions;
74
+ this.skills = skills;
75
+ this.attachments = attachments;
76
+ // Precedence: an explicitly bound token, then the host's config, then
77
+ // the defaults. Config is the ordinary path (it is env-driven); the
78
+ // token stays for tests that need to pin a limit without a whole config.
79
+ this.limits = limits ?? { ...copilot_domain_1.DEFAULT_RUN_LIMITS, ...config.limits };
80
+ }
81
+ /** Runs one turn, yielding events as they happen. */
82
+ async *run(input) {
83
+ if (!this.config.enabled) {
84
+ // The operator's kill switch (ADR-0005 §10). Checked here rather
85
+ // than in a guard so the reason reaches the user as a normal
86
+ // answer instead of an opaque 403.
87
+ throw new CopilotDisabledError();
88
+ }
89
+ const runId = (0, node_crypto_1.randomUUID)();
90
+ const startedAt = Date.now();
91
+ // Resolved BEFORE the conversation is touched. An unresolvable
92
+ // attachment is a bad request, and doing it here means it cannot leave
93
+ // a thread holding a turn that references a file the model was never
94
+ // told about — the one ordering that makes the transcript trustworthy.
95
+ const attachments = await this.resolveAttachments(input.attachments, input.workspaceId);
96
+ // Resolved here for the same reason, and before the same line: a turn
97
+ // must never be persisted claiming skills the model was not actually
98
+ // given. `SkillResolutionError` reaches the controller as an error
99
+ // frame, exactly like a bad attachment.
100
+ const { available: availableSkills, inForce: skillsInForce } = await this.skills.resolveRunSkills(input.workspaceId, input.skills ?? []);
101
+ const skillRefs = skillsInForce.map(copilot_domain_1.toSkillRef);
102
+ const conversation = input.conversationId
103
+ ? await this.conversations.findOrFail(input.conversationId, input.userId, input.workspaceId)
104
+ : await this.conversations.create(input.userId, input.workspaceId, input.context.surface ?? 'chat', input.message);
105
+ // The user's message is persisted BEFORE the model is called, so a
106
+ // dropped connection never loses what someone typed (design §5, step 3).
107
+ const userMessage = await this.conversations.appendMessage({
108
+ conversationId: conversation.id,
109
+ runId,
110
+ role: 'user',
111
+ content: [{ type: 'text', text: input.message }],
112
+ attachments,
113
+ skills: skillRefs
114
+ });
115
+ yield {
116
+ type: 'run-started',
117
+ conversationId: conversation.id,
118
+ runId,
119
+ messageId: userMessage.id
120
+ };
121
+ const authority = await this.profiles.resolve({
122
+ id: input.userId,
123
+ email: input.userEmail,
124
+ roleId: input.roleId
125
+ }, input.workspaceId, input.signal);
126
+ const profile = authority.profile;
127
+ // An explicitly requested provider wins over the host's resolver: the
128
+ // resolver expresses a default routing policy, not a veto over what the
129
+ // user picked from the catalogue they were shown.
130
+ const providerName = input.choice?.provider ??
131
+ this.resolver({ workspaceId: input.workspaceId, userId: input.userId }, this.registry);
132
+ if (!this.registry.has(providerName)) {
133
+ throw new UnknownModelChoiceError(`Unknown model provider "${providerName}".`);
134
+ }
135
+ const provider = this.registry.get(providerName);
136
+ // Resolved once, here, rather than left to the adapter: the run record
137
+ // has to name the model that actually answered, and an adapter
138
+ // resolving it internally would leave us writing `null` into
139
+ // `copilot_messages.model` — exactly the column cost accounting and
140
+ // "which model said this?" both read.
141
+ const model = (0, copilot_domain_1.resolveModel)(input.choice?.model, provider.models());
142
+ const system = (0, system_prompt_1.buildSystemPrompt)({
143
+ uiLocale: input.uiLocale,
144
+ context: input.context,
145
+ typeSummaries: input.typeSummaries,
146
+ toolNames: profile.tools.map((tool) => tool.name),
147
+ hasWriteTools: profile.tools.some((tool) => tool.effect !== 'read'),
148
+ availableSkills,
149
+ skillsInForce
150
+ });
151
+ // The thread so far, plus this turn. Read from the transcript rather
152
+ // than held in memory, so a continued conversation replays exactly what
153
+ // was persisted.
154
+ const history = await this.loadHistory(conversation.id);
155
+ const usage = { inputTokens: 0, outputTokens: 0 };
156
+ const assistantBlocks = [];
157
+ let stopReason = 'end';
158
+ try {
159
+ stopReason = yield* this.loop({
160
+ input,
161
+ runId,
162
+ conversationId: conversation.id,
163
+ authority,
164
+ provider: { name: providerName, provider },
165
+ model,
166
+ system,
167
+ history,
168
+ usage,
169
+ assistantBlocks,
170
+ startedAt
171
+ });
172
+ }
173
+ catch (error) {
174
+ if ((0, copilot_domain_1.isAbortError)(error, input.signal)) {
175
+ stopReason = 'aborted';
176
+ }
177
+ else {
178
+ stopReason = 'error';
179
+ this.logger.error(`Copilot run ${runId} failed`, error instanceof Error ? error.stack : String(error));
180
+ yield { type: 'error', message: userFacingMessage(error) };
181
+ }
182
+ }
183
+ // The assistant turn is persisted even when the run was cancelled or
184
+ // failed: the transcript is append-only and a partial answer is part of
185
+ // what happened. Skipped only when nothing at all was produced.
186
+ let assistantMessageId;
187
+ if (assistantBlocks.length > 0) {
188
+ const written = await this.conversations.appendMessage({
189
+ conversationId: conversation.id,
190
+ runId,
191
+ role: 'assistant',
192
+ content: assistantBlocks,
193
+ model,
194
+ provider: providerName,
195
+ stopReason,
196
+ inputTokens: usage.inputTokens,
197
+ outputTokens: usage.outputTokens
198
+ });
199
+ assistantMessageId = written.id;
200
+ }
201
+ yield {
202
+ type: 'done',
203
+ stopReason,
204
+ usage,
205
+ ...(assistantMessageId ? { messageId: assistantMessageId } : {})
206
+ };
207
+ }
208
+ /**
209
+ * The loop itself: model call → tool calls → repeat. Returns the reason the
210
+ * run ended.
211
+ */
212
+ async *loop(ctx) {
213
+ const messages = [...ctx.history];
214
+ const tools = toModelTools(ctx.authority.profile.tools);
215
+ // Signatures of calls already made this run, so a model that asks for
216
+ // the same thing twice is told rather than silently obliged.
217
+ const alreadyCalled = new Set();
218
+ for (let step = 0; step < this.limits.maxSteps; step += 1) {
219
+ if (ctx.input.signal.aborted) {
220
+ return 'aborted';
221
+ }
222
+ if (Date.now() - ctx.startedAt > this.limits.wallClockMs) {
223
+ return 'timeout';
224
+ }
225
+ if (totalTokens(ctx.usage) > this.limits.maxTotalTokens) {
226
+ return 'max-tokens';
227
+ }
228
+ // `yield*` forwards each delta to the client as it arrives and
229
+ // still gives us the turn's summary as the generator's return
230
+ // value — the same shape `loop` itself uses to return its stop
231
+ // reason.
232
+ const turn = yield* this.streamTurn(ctx, messages, tools);
233
+ ctx.usage.inputTokens += turn.usage.inputTokens;
234
+ ctx.usage.outputTokens += turn.usage.outputTokens;
235
+ // The cache figures accumulate too, and are reported on `done`
236
+ // alongside the other two. A provider that reports neither leaves
237
+ // both keys absent rather than zero, so a run on an adapter with no
238
+ // cache is indistinguishable from one that never hit it.
239
+ addCacheTokens(ctx.usage, turn.usage);
240
+ if (turn.text) {
241
+ ctx.assistantBlocks.push({ type: 'text', text: turn.text });
242
+ }
243
+ ctx.assistantBlocks.push(...turn.toolUses);
244
+ if (turn.stopReason === 'aborted')
245
+ return 'aborted';
246
+ if (turn.stopReason === 'refusal')
247
+ return 'refusal';
248
+ if (turn.stopReason === 'max_tokens')
249
+ return 'max-output-tokens';
250
+ if (turn.toolUses.length === 0)
251
+ return 'end';
252
+ // The model asked for tools. Everything it said this turn — text
253
+ // and tool_use blocks together — goes back as one assistant turn,
254
+ // then the results ride on a user turn, which is what both wire
255
+ // formats expect.
256
+ messages.push({
257
+ role: 'assistant',
258
+ content: [
259
+ ...(turn.text
260
+ ? [{ type: 'text', text: turn.text }]
261
+ : []),
262
+ ...turn.toolUses
263
+ ]
264
+ });
265
+ const results = [];
266
+ let ranOutOfTime = false;
267
+ for (const call of turn.toolUses) {
268
+ yield {
269
+ type: 'tool-call',
270
+ id: call.id,
271
+ name: call.name,
272
+ input: call.input
273
+ };
274
+ // The ceilings above are checked once per **step**, and one
275
+ // step may ask for any number of tools — each of which can
276
+ // park for a human answer. A turn requesting thirty writes
277
+ // therefore ran all thirty however long they took, and only
278
+ // reported `timeout` afterwards, at the top of a step it was
279
+ // never going to reach. Checked per call, the ceiling bounds
280
+ // what it says it bounds.
281
+ //
282
+ // Refused rather than dropped: every `tool_use` block needs a
283
+ // matching `tool_result` or the turn we just pushed is
284
+ // malformed for both wire formats.
285
+ if (Date.now() - ctx.startedAt > this.limits.wallClockMs) {
286
+ ranOutOfTime = true;
287
+ const refusal = await this.toolFailure(ctx, call, `"${call.name}" was not run: this run reached its time limit.`);
288
+ results.push(refusal.block);
289
+ for (const event of refusal.events)
290
+ yield event;
291
+ continue;
292
+ }
293
+ // **Ask before running, not after.** This is the one place a
294
+ // call the model was talked into by poisoned content can still
295
+ // be stopped without anything having happened — which is the
296
+ // mitigation ADR-0009's Consequences left owing.
297
+ const gate = await this.mayRun(ctx, call);
298
+ if (gate) {
299
+ yield gate.event;
300
+ const refusal = await gate.settle();
301
+ if (refusal) {
302
+ results.push(refusal.block);
303
+ for (const event of refusal.events)
304
+ yield event;
305
+ continue;
306
+ }
307
+ }
308
+ const outcome = await this.executeTool(ctx, call, alreadyCalled);
309
+ results.push(outcome.block);
310
+ // More than one event when the call produced a proposal: the
311
+ // tool result is what the model was told, the proposal is the
312
+ // receipt the card draws. (It used to say "what the human is
313
+ // being asked to decide" — ADR-0009 removed the deciding.)
314
+ for (const event of outcome.events) {
315
+ yield event;
316
+ }
317
+ }
318
+ ctx.assistantBlocks.push(...results);
319
+ if (ranOutOfTime) {
320
+ return 'timeout';
321
+ }
322
+ messages.push({ role: 'user', content: results });
323
+ }
324
+ return 'max-steps';
325
+ }
326
+ /** One model call, reduced to text, tool uses, usage and a stop reason. */
327
+ async *streamTurn(ctx, messages, tools) {
328
+ const toolUses = [];
329
+ let text = '';
330
+ let usage = { inputTokens: 0, outputTokens: 0 };
331
+ let stopReason = 'end';
332
+ const stream = ctx.provider.provider.stream({
333
+ model: ctx.model,
334
+ system: ctx.system,
335
+ messages,
336
+ ...(tools.length > 0 ? { tools } : {}),
337
+ maxOutputTokens: this.config.maxOutputTokens
338
+ }, ctx.input.signal);
339
+ for await (const event of stream) {
340
+ if (event.type === 'text-delta') {
341
+ text += event.text;
342
+ // Yielded, not collected. Buffering these into an array and
343
+ // flushing after the provider's stream ends turns the whole
344
+ // feature off: the answer arrives in one burst when the model
345
+ // finishes, which looks exactly like a slow non-streaming API.
346
+ yield { type: 'text-delta', text: event.text };
347
+ }
348
+ else if (event.type === 'tool-call') {
349
+ toolUses.push({
350
+ type: 'tool_use',
351
+ id: event.id,
352
+ name: event.name,
353
+ input: event.input
354
+ });
355
+ }
356
+ else {
357
+ usage = event.usage;
358
+ stopReason = event.stopReason;
359
+ }
360
+ }
361
+ return { text, toolUses, usage, stopReason };
362
+ }
363
+ /**
364
+ * Authorizes, validates and runs one tool call, and writes its audit row.
365
+ *
366
+ * **Never throws.** Every failure — unknown tool, revoked permission,
367
+ * malformed arguments, a tool that blew up — comes back as a tool *error*
368
+ * the model can recover from, and the run continues (design §5, step 6).
369
+ * Turning a model mistake into a 500 would lose the whole turn.
370
+ */
371
+ /**
372
+ * Decides whether `call` needs the user's say-so, and parks the run if so.
373
+ *
374
+ * Returns `null` when it may just run — which is the overwhelmingly common
375
+ * case, because **only write tools ask**. Prompting on reads was
376
+ * considered and rejected: a model does three or four searches before it
377
+ * answers anything, so a fresh chat would open with four prompts, and
378
+ * everyone would learn to click through them without reading. That is worse
379
+ * than not asking, because it also devalues the prompt that matters.
380
+ *
381
+ * Otherwise it hands back the frame to emit and a `settle` to await, rather
382
+ * than doing both itself — the caller is the generator, and only a
383
+ * generator can `yield`.
384
+ */
385
+ async mayRun(ctx, call) {
386
+ const tool = ctx.authority.profile.tools.find((entry) => entry.name === call.name);
387
+ // An unknown tool is `executeTool`'s to refuse, with its own message.
388
+ // Gating it here would ask the user to approve something that does not
389
+ // exist.
390
+ if (!tool)
391
+ return null;
392
+ if (tool.effect !== 'propose' && tool.effect !== 'apply')
393
+ return null;
394
+ // Read per call, not per run: the list grows while the run is parked —
395
+ // answering "allow for this chat" on the first of two calls in one turn
396
+ // must stop the second from asking.
397
+ const allowed = await this.conversations.allowedTools(ctx.conversationId);
398
+ if (allowed.includes(call.name))
399
+ return null;
400
+ return {
401
+ event: {
402
+ type: 'tool-permission-request',
403
+ id: call.id,
404
+ runId: ctx.runId,
405
+ name: call.name,
406
+ ...(tool.title ? { title: tool.title } : {}),
407
+ input: call.input,
408
+ // Read here rather than inside `settle`, because the frame is
409
+ // emitted before the run parks — and the client needs the
410
+ // deadline to count down to from the moment the prompt appears.
411
+ expiresAt: new Date(Date.now() + this.permissions.budgetRemaining(ctx.runId)).toISOString()
412
+ },
413
+ settle: async () => {
414
+ const outcome = await this.permissions.ask(ctx.runId, call.id,
415
+ // Who may answer. Recorded when the run parks rather than
416
+ // checked on the way in, because by then the answering
417
+ // request has only a `runId` — which is not a secret.
418
+ {
419
+ userId: ctx.input.userId,
420
+ workspaceId: ctx.input.workspaceId
421
+ }, ctx.input.signal);
422
+ if (outcome.decision === 'chat') {
423
+ await this.conversations.allowTool(ctx.conversationId, call.name);
424
+ }
425
+ if (outcome.decision !== 'deny')
426
+ return null;
427
+ // A refusal is an ordinary tool error, so the model reports it
428
+ // and carries on rather than the run dying — the same treatment
429
+ // an unknown tool or a revoked permission gets. It is audited
430
+ // too: "the user said no" is exactly what a reviewer reading
431
+ // `copilot_tool_calls` wants to see.
432
+ const message = outcome.timedOut
433
+ ? `"${call.name}" was not run: nobody answered the request to allow it.`
434
+ : `"${call.name}" was not run: the user did not allow it.`;
435
+ await this.audit(ctx, call, {
436
+ ok: false,
437
+ error: message,
438
+ durationMs: 0,
439
+ outputSummary: null
440
+ });
441
+ return {
442
+ block: {
443
+ type: 'tool_result',
444
+ toolUseId: call.id,
445
+ content: message,
446
+ isError: true
447
+ },
448
+ events: [
449
+ {
450
+ type: 'tool-result',
451
+ id: call.id,
452
+ name: call.name,
453
+ ok: false,
454
+ durationMs: 0,
455
+ summary: outcome.timedOut
456
+ ? 'no answer'
457
+ : 'not allowed',
458
+ error: message
459
+ }
460
+ ]
461
+ };
462
+ }
463
+ };
464
+ }
465
+ async executeTool(ctx, call, alreadyCalled) {
466
+ const startedAt = Date.now();
467
+ const fail = (message) => this.toolFailure(ctx, call, message, Date.now() - startedAt);
468
+ const tool = ctx.authority.profile.tools.find((entry) => entry.name === call.name);
469
+ if (!tool) {
470
+ // A hallucinated name, or one the offer withheld. Both answer
471
+ // "unknown" — which is what the shared registry says too. The tool
472
+ // set is not secret: it is derived from the caller's own role,
473
+ // which they can read off their own profile, so naming it reveals
474
+ // nothing and saves the model a wasted turn. (This used to answer
475
+ // uniformly to avoid an enumeration signal; that reasoning is right
476
+ // for *data* — an ungranted content type still 404s like an unknown
477
+ // one — and wrong for the tool list. ADR-0006 §5 reached the same
478
+ // conclusion for MCP, and one registry should not answer two ways.)
479
+ return fail(`Unknown tool "${call.name}".`);
480
+ }
481
+ // Re-authorize against freshly resolved grants (ADR-0005 §3): the offer
482
+ // was computed at the start of the run, and a role can change while a
483
+ // long turn is in flight.
484
+ const fresh = await this.profiles.resolve({
485
+ id: ctx.input.userId,
486
+ email: ctx.input.userEmail,
487
+ roleId: ctx.input.roleId
488
+ }, ctx.input.workspaceId, ctx.input.signal);
489
+ if (!fresh.profile.tools.some((entry) => entry.name === call.name)) {
490
+ this.logger.warn(`Run ${ctx.runId}: tool "${call.name}" was offered but is no longer permitted; refused.`);
491
+ return fail(`You are not permitted to use "${call.name}".`);
492
+ }
493
+ const validation = (0, tools_server_1.validateToolInput)(call.input, tool.inputSchema);
494
+ if (!validation.valid) {
495
+ return fail(`Invalid arguments: ${validation.errors.join('; ')}`);
496
+ }
497
+ // The loop guard. A model — especially a smaller local one — will
498
+ // sometimes re-request a call it has already made instead of using the
499
+ // result, and without this the engine obliges every time until it hits
500
+ // `maxSteps`: eight model calls, eight identical queries, no answer, and
501
+ // a stop reason that says nothing about why.
502
+ //
503
+ // Telling it what happened is what actually breaks the loop; silently
504
+ // re-running, or refusing without saying why, both just repeat. Checked
505
+ // AFTER authorization so a repeat can never reveal more than a first
506
+ // call would.
507
+ const signature = `${call.name} ${stableStringify(call.input)}`;
508
+ if (alreadyCalled.has(signature)) {
509
+ this.logger.warn(`Run ${ctx.runId}: "${call.name}" repeated with identical arguments; refusing.`);
510
+ return fail(`You already called "${call.name}" with exactly these arguments in this ` +
511
+ 'conversation, and its result is above. Use that result, or call a ' +
512
+ 'different tool, or answer the question — do not repeat this call.');
513
+ }
514
+ alreadyCalled.add(signature);
515
+ try {
516
+ // Dispatched through the shared registry, not called directly:
517
+ // `ToolRegistry.call` re-checks `requires` before it runs the
518
+ // handler, which is the one authorization point both consumers
519
+ // share (ADR-0006 §5). The offer above is a usability filter; this
520
+ // is the boundary.
521
+ const output = await this.tools.call(call.name, (call.input ?? {}), fresh.context, 'copilot');
522
+ // A `propose` tool's return value IS the change. The engine
523
+ // persists it and then applies it — every one of them, since
524
+ // ADR-0009; the per-workspace opt-in this comment used to name is
525
+ // gone. So ADR-0005 §5's guarantees live in one place instead of
526
+ // once per binder, and the row written *before* the write is the
527
+ // whole of what makes a change "undoable, never invisible".
528
+ if (tool.effect === 'propose') {
529
+ return await this.recordProposal(ctx, call, output, startedAt);
530
+ }
531
+ // An `apply` tool writes for itself. ADR-0009 §5 keeps offering one
532
+ // exactly as it would a read tool, and §2 keeps the row as "the
533
+ // whole paper trail" — so the receipt has to be written here, or an
534
+ // apply tool is precisely the "change with no receipt" §2 forbids.
535
+ if (tool.effect === 'apply') {
536
+ return await this.recordApplied(ctx, call, tool, output, startedAt);
537
+ }
538
+ const durationMs = Date.now() - startedAt;
539
+ const summary = (0, summarize_tool_output_1.summarizeToolOutput)(output);
540
+ await this.audit(ctx, call, {
541
+ ok: true,
542
+ error: null,
543
+ durationMs,
544
+ outputSummary: summary
545
+ });
546
+ return {
547
+ block: {
548
+ type: 'tool_result',
549
+ toolUseId: call.id,
550
+ // Fenced as untrusted data: entry bodies are user-authored
551
+ // and must enter the model as material, never instructions.
552
+ content: (0, copilot_domain_1.fenceUntrusted)(call.name, output)
553
+ },
554
+ events: [
555
+ {
556
+ type: 'tool-result',
557
+ id: call.id,
558
+ name: call.name,
559
+ ok: true,
560
+ durationMs,
561
+ summary,
562
+ output
563
+ }
564
+ ]
565
+ };
566
+ }
567
+ catch (error) {
568
+ this.logger.warn(`Run ${ctx.runId}: tool "${call.name}" threw`, error instanceof Error ? error.stack : String(error));
569
+ return fail(userFacingMessage(error));
570
+ }
571
+ }
572
+ /**
573
+ * Persists a `propose` tool's draft, applies it, and produces both the
574
+ * model's receipt and the client's proposal card.
575
+ *
576
+ * Applies it **always** — the per-workspace auto-apply opt-in this doc used
577
+ * to name was deleted with the policy table
578
+ * ([ADR-0009](../../../../../../docs/adr/0009-copilot-applies-directly.md)
579
+ * §4). The row is still written first, and that ordering is now the only
580
+ * thing carrying "undoable, never invisible".
581
+ *
582
+ * **The model is told what happened, not handed the patch back.** It
583
+ * already knows what it asked for; echoing the whole change would spend the
584
+ * tokens twice and invite the model to "confirm" by proposing again. What
585
+ * it needs is the id, the summary, and whether the write landed — which is
586
+ * what makes it say "I've fixed the headline" or "that did not save",
587
+ * rather than the pre-ADR-0009 "I've drafted this, accept it below" for a
588
+ * change that has already happened.
589
+ */
590
+ async recordProposal(ctx, call, output, startedAt) {
591
+ // A binder that declared `effect: 'propose'` and returned something
592
+ // else would otherwise write a malformed row into an append-only
593
+ // table. This turns that into an ordinary tool error — the same
594
+ // treatment every other binder bug gets.
595
+ if (!(0, copilot_domain_1.isProposalDraft)(output)) {
596
+ const message = `"${call.name}" did not return a valid change.`;
597
+ this.logger.error(`Run ${ctx.runId}: propose tool "${call.name}" returned a value ` +
598
+ 'that is not a ProposalDraft; nothing was recorded.');
599
+ return this.toolFailure(ctx, call, message, Date.now() - startedAt);
600
+ }
601
+ const draft = output;
602
+ let proposal = await this.proposals.create({
603
+ conversationId: ctx.conversationId,
604
+ runId: ctx.runId,
605
+ toolCallId: call.id,
606
+ toolName: call.name,
607
+ kind: draft.kind,
608
+ workspaceId: ctx.input.workspaceId,
609
+ createdBy: ctx.input.userId,
610
+ target: draft.target,
611
+ patch: draft.patch,
612
+ summary: draft.summary,
613
+ ...(draft.changes ? { changes: draft.changes } : {})
614
+ });
615
+ // **Every change applies, immediately** (ADR-0009). The row above is
616
+ // written first regardless, and that ordering is now the only thing
617
+ // carrying ADR-0005 §5's "undoable, never invisible": nothing waits for
618
+ // a human any more, so the receipt is the whole paper trail.
619
+ let applyError;
620
+ const outcome = await this.decisions.apply(proposal, {
621
+ userId: ctx.input.userId,
622
+ email: ctx.input.userEmail,
623
+ workspaceId: ctx.input.workspaceId
624
+ });
625
+ if (outcome.ok) {
626
+ proposal = outcome.proposal;
627
+ }
628
+ else {
629
+ // The row survives as `pending` with its error recorded. Nobody
630
+ // will retry it — there is no accept endpoint — so this is a
631
+ // receipt saying the change did not happen, and the model is told
632
+ // as much so it can report the failure rather than claim success.
633
+ applyError = outcome.message;
634
+ }
635
+ const durationMs = Date.now() - startedAt;
636
+ const applied = proposal.status === 'accepted';
637
+ const receipt = applied
638
+ ? `Applied: ${draft.summary}`
639
+ : `NOT applied: ${draft.summary}. The change failed and nothing was ` +
640
+ `written (${applyError}). Tell the user it did not happen, and do ` +
641
+ 'not claim otherwise.';
642
+ const summary = applied ? 'applied' : 'failed';
643
+ await this.audit(ctx, call, {
644
+ // The audit row is the security-review surface, so it has to agree
645
+ // with the outcome rather than with the dispatch. It read `ok:
646
+ // true` beside `output_summary: "failed: …"` and a non-null
647
+ // `error` — a reviewer filtering for `ok = false` would find no
648
+ // trace of a write that never landed, which is exactly the row
649
+ // they were looking for.
650
+ ok: !applyError,
651
+ error: applyError ?? null,
652
+ durationMs,
653
+ outputSummary: `${summary}: ${draft.summary}`
654
+ });
655
+ return {
656
+ block: {
657
+ type: 'tool_result',
658
+ toolUseId: call.id,
659
+ content: (0, copilot_domain_1.fenceUntrusted)(call.name, {
660
+ proposalId: proposal.id,
661
+ status: proposal.status,
662
+ message: receipt,
663
+ ...(applyError ? { applyError } : {})
664
+ })
665
+ },
666
+ events: [
667
+ {
668
+ type: 'tool-result',
669
+ id: call.id,
670
+ name: call.name,
671
+ // The **UI** event, not the model's block: a write that did
672
+ // not land is a failed step and must draw as one. It read
673
+ // `ok: true` with `summary: 'failed'` — a green tick beside
674
+ // the word "failed". The block the model gets stays a
675
+ // normal result whose text says NOT applied, because that
676
+ // is a receipt to report, not an error to recover from.
677
+ ok: !applyError,
678
+ durationMs,
679
+ summary,
680
+ output: {
681
+ proposalId: proposal.id,
682
+ status: proposal.status,
683
+ ...(applyError ? { applyError } : {})
684
+ }
685
+ },
686
+ {
687
+ type: 'proposal',
688
+ id: proposal.id,
689
+ toolCallId: call.id,
690
+ toolName: call.name,
691
+ kind: proposal.kind,
692
+ summary: proposal.summary,
693
+ target: proposal.target,
694
+ ...(proposal.changes ? { changes: proposal.changes } : {}),
695
+ status: proposal.status,
696
+ ...(typeof proposal.result?.['entityId'] === 'string'
697
+ ? { entityId: proposal.result['entityId'] }
698
+ : {}),
699
+ // The card has to be able to say the change did not
700
+ // happen, and this frame is its only chance — there is no
701
+ // review queue to go and look it up in.
702
+ ...(applyError ? { error: applyError } : {})
703
+ }
704
+ ]
705
+ };
706
+ }
707
+ /**
708
+ * Records the receipt for an `effect: 'apply'` tool — one that does its own
709
+ * writing instead of handing back a draft for the engine to apply.
710
+ *
711
+ * The registry has always allowed the effect and
712
+ * [ADR-0009](../../../../../../docs/adr/0009-copilot-applies-directly.md)
713
+ * §5 still offers such a tool "exactly when a `read` one with the same
714
+ * `requires` would be", while §2 keeps the `copilot_proposals` row as "the
715
+ * whole paper trail" and names a binder that writes directly as the failure
716
+ * the split exists to prevent. Both were true at once: an `apply` tool
717
+ * parked for permission like a write, ran like a write, and left nothing
718
+ * behind. Nothing shipped declares the effect today, which is why it went
719
+ * unnoticed — and is exactly why the next binder to reach for it must not
720
+ * have to know this.
721
+ *
722
+ * Two differences from {@link recordProposal}, both forced by the effect:
723
+ *
724
+ * - **The row is written after the fact.** The write has already happened
725
+ * by the time the handler returns; there was never a moment this engine
726
+ * could have stopped it. The permission prompt is what does that.
727
+ * - **`kind` is `tool.<name>` and has no applier.** There is nothing left
728
+ * to carry out. The row exists to be read — which is the whole of what
729
+ * "undoable, never invisible" asks of it here.
730
+ *
731
+ * The model still gets the handler's own return value: for an `apply` tool
732
+ * the return value is a *result*, not a change (`ToolEffect`).
733
+ */
734
+ async recordApplied(ctx, call, tool, output, startedAt) {
735
+ const summary = `${tool.title || call.name} ran.`;
736
+ const created = await this.proposals.create({
737
+ conversationId: ctx.conversationId,
738
+ runId: ctx.runId,
739
+ toolCallId: call.id,
740
+ toolName: call.name,
741
+ kind: `tool.${call.name}`,
742
+ workspaceId: ctx.input.workspaceId,
743
+ createdBy: ctx.input.userId,
744
+ target: { tool: call.name },
745
+ // The arguments are the change, as far as anything here can know:
746
+ // the shape of what the handler wrote belongs to the handler.
747
+ patch: (call.input ?? {}),
748
+ summary
749
+ });
750
+ const outputSummary = (0, summarize_tool_output_1.summarizeToolOutput)(output);
751
+ // Accepted in the same breath, because it already happened. `decide`
752
+ // rather than a second insert so the row carries `decidedBy`/`decidedAt`
753
+ // like every other applied change.
754
+ const proposal = (await this.proposals.decide(created.id, ctx.input.workspaceId, 'accepted', ctx.input.userId, { detail: outputSummary })) ?? created;
755
+ const durationMs = Date.now() - startedAt;
756
+ await this.audit(ctx, call, {
757
+ ok: true,
758
+ error: null,
759
+ durationMs,
760
+ outputSummary
761
+ });
762
+ return {
763
+ block: {
764
+ type: 'tool_result',
765
+ toolUseId: call.id,
766
+ content: (0, copilot_domain_1.fenceUntrusted)(call.name, output)
767
+ },
768
+ events: [
769
+ {
770
+ type: 'tool-result',
771
+ id: call.id,
772
+ name: call.name,
773
+ ok: true,
774
+ durationMs,
775
+ summary: outputSummary,
776
+ output
777
+ },
778
+ {
779
+ type: 'proposal',
780
+ id: proposal.id,
781
+ toolCallId: call.id,
782
+ toolName: call.name,
783
+ kind: proposal.kind,
784
+ summary: proposal.summary,
785
+ target: proposal.target,
786
+ status: proposal.status
787
+ }
788
+ ]
789
+ };
790
+ }
791
+ /**
792
+ * One tool call that did not happen: the audit row, the block the model is
793
+ * told, and the frame the UI draws.
794
+ *
795
+ * Shared by `executeTool`'s own refusals and by the loop's time-limit
796
+ * check, so a call refused before it was ever dispatched is recorded
797
+ * exactly like one refused after — a reviewer reading `copilot_tool_calls`
798
+ * should not have to know which stage said no.
799
+ */
800
+ async toolFailure(ctx, call, message, durationMs = 0) {
801
+ await this.audit(ctx, call, {
802
+ ok: false,
803
+ error: message,
804
+ durationMs,
805
+ outputSummary: null
806
+ });
807
+ return {
808
+ block: {
809
+ type: 'tool_result',
810
+ toolUseId: call.id,
811
+ content: message,
812
+ isError: true
813
+ },
814
+ events: [
815
+ {
816
+ type: 'tool-result',
817
+ id: call.id,
818
+ name: call.name,
819
+ ok: false,
820
+ durationMs,
821
+ summary: 'failed',
822
+ error: message
823
+ }
824
+ ]
825
+ };
826
+ }
827
+ /** Writes one `copilot_tool_calls` row. Never fails the run. */
828
+ async audit(ctx, call, outcome) {
829
+ try {
830
+ await this.conversations.recordToolCall({
831
+ conversationId: ctx.conversationId,
832
+ runId: ctx.runId,
833
+ callId: call.id,
834
+ name: call.name,
835
+ input: call.input,
836
+ ...outcome
837
+ });
838
+ }
839
+ catch (error) {
840
+ // An audit write failing must not take the answer down with it,
841
+ // but it is exactly the kind of thing that has to be loud.
842
+ this.logger.error(`Failed to record tool call "${call.name}" for run ${ctx.runId}`, error instanceof Error ? error.stack : String(error));
843
+ }
844
+ }
845
+ /**
846
+ * Turns the attached asset ids into what the model is told about them.
847
+ *
848
+ * Three refusals, each a different wrong thing to do:
849
+ *
850
+ * - **No resolver bound** — the deployment has no media plugin, so there is
851
+ * nothing that could have produced these ids. Failing beats describing
852
+ * files nobody can look up.
853
+ * - **An id that did not resolve** — it belongs to another workspace, or to
854
+ * a file deleted between the upload and the send. The resolver is
855
+ * workspace-scoped and omits what it cannot see, so the two are
856
+ * indistinguishable here, which is deliberate: telling a caller *which*
857
+ * of their ids exists elsewhere is an oracle.
858
+ * - Neither case is reported per-id. One message names the count, because
859
+ * the user's recourse is the same either way — re-attach the file.
860
+ */
861
+ async resolveAttachments(assetIds, workspaceId) {
862
+ if (!assetIds?.length) {
863
+ return [];
864
+ }
865
+ if (!this.attachments) {
866
+ throw new AttachmentError('Files cannot be attached in this deployment.');
867
+ }
868
+ // De-duplicated first: the same file attached twice is a client slip,
869
+ // not a reason to spend two lines of prompt on it — and it would make
870
+ // the count check below fail on a request that is otherwise fine.
871
+ const unique = [...new Set(assetIds)];
872
+ const resolved = await this.attachments.resolve(unique, workspaceId);
873
+ if (resolved.length !== unique.length) {
874
+ const missing = unique.length - resolved.length;
875
+ throw new AttachmentError(missing === 1
876
+ ? 'One of the attached files is no longer available.'
877
+ : `${missing} of the attached files are no longer available.`);
878
+ }
879
+ // Returned in the order they were attached rather than the order the
880
+ // resolver happened to answer in, so the prompt and the chips agree.
881
+ const byId = new Map(resolved.map((ref) => [ref.assetId, ref]));
882
+ return unique
883
+ .map((id) => byId.get(id))
884
+ .filter((ref) => ref !== undefined);
885
+ }
886
+ /**
887
+ * The persisted transcript, as the port's message shape.
888
+ *
889
+ * A turn's attachments are folded back in as a **fenced text block**, which
890
+ * is what makes them survive into later turns: "summarise the file I sent"
891
+ * on turn three has to reach a manifest written on turn one, and the
892
+ * transcript is the only thing carried forward. Fenced because a file name
893
+ * is user-authored text arriving in the prompt — the same treatment a tool
894
+ * result gets, for the same reason.
895
+ *
896
+ * The rows are then run through `normalizeTranscript`, which is what makes
897
+ * a *continued* conversation replayable at all. A run is stored as one
898
+ * assistant row holding text, `tool_use` **and** `tool_result` blocks
899
+ * together — the shape the transcript UI reads — and Anthropic rejects that
900
+ * on the way back in: a tool result has to ride on a user turn after the
901
+ * assistant turn that asked for it. It also drops `tool_use` blocks left
902
+ * unanswered by a run that was aborted mid-step, which is the same 400
903
+ * wearing a different message. The repair is here rather than at the write
904
+ * so it also fixes the threads already in the database.
905
+ */
906
+ async loadHistory(conversationId) {
907
+ const rows = await this.conversations.messages(conversationId);
908
+ const turns = rows.map((row) => {
909
+ const extra = [];
910
+ if (row.attachments?.length) {
911
+ extra.push({
912
+ type: 'text',
913
+ text: attachmentManifest(row.attachments)
914
+ });
915
+ }
916
+ // A **note**, never the bodies again. The instructions a past turn
917
+ // ran with are already reflected in the answer it produced, and
918
+ // re-injecting them per turn would multiply the prompt by the
919
+ // length of the thread — five turns under one always-on skill would
920
+ // carry five copies of it. What the model still needs is why an
921
+ // earlier answer reads the way it does, which a name supplies.
922
+ if (row.skills?.length) {
923
+ extra.push({ type: 'text', text: skillNote(row.skills) });
924
+ }
925
+ // Why an assistant turn stops short of an answer is the one thing
926
+ // about it the model cannot read off its own output — the ceilings
927
+ // belong to the engine, and `RunStopReason` says outright that they
928
+ // are "limits the engine imposes and the model never sees". So a
929
+ // turn cut off at a ceiling replays looking exactly like a turn that
930
+ // simply finished, and "continue" gets a fresh start on the whole
931
+ // task instead of a resumption of the interrupted one.
932
+ if ((0, copilot_domain_1.wasCutShort)(row.stopReason)) {
933
+ extra.push({
934
+ type: 'text',
935
+ text: (0, copilot_domain_1.interruptionNote)(row.stopReason)
936
+ });
937
+ }
938
+ return {
939
+ role: row.role,
940
+ content: extra.length > 0 ? [...row.content, ...extra] : row.content
941
+ };
942
+ });
943
+ return (0, copilot_domain_1.normalizeTranscript)(turns);
944
+ }
945
+ };
946
+ exports.RunEngine = RunEngine;
947
+ exports.RunEngine = RunEngine = RunEngine_1 = tslib_1.__decorate([
948
+ (0, common_1.Injectable)(),
949
+ tslib_1.__param(0, (0, common_1.Inject)(copilot_domain_1.MODEL_REGISTRY)),
950
+ tslib_1.__param(1, (0, common_1.Inject)(copilot_domain_1.MODEL_RESOLVER)),
951
+ tslib_1.__param(2, (0, copilot_tokens_1.InjectCopilotConfig)()),
952
+ tslib_1.__param(10, (0, common_1.Optional)()),
953
+ tslib_1.__param(10, (0, common_1.Inject)(copilot_domain_1.COPILOT_ATTACHMENT_RESOLVER)),
954
+ tslib_1.__param(11, (0, common_1.Optional)()),
955
+ tslib_1.__param(11, (0, common_1.Inject)(copilot_tokens_1.COPILOT_RUN_LIMITS)),
956
+ tslib_1.__metadata("design:paramtypes", [Object, Function, Object, capability_profile_service_1.CapabilityProfileService,
957
+ tools_server_1.ToolRegistry,
958
+ conversation_repository_1.ConversationRepository,
959
+ proposal_repository_1.ProposalRepository,
960
+ decide_proposal_service_1.DecideProposalService,
961
+ tool_permission_broker_1.ToolPermissionBroker,
962
+ skill_catalog_service_1.SkillCatalogService, Object, Object])
963
+ ], RunEngine);
964
+ /**
965
+ * Thrown when a turn's attachments cannot be resolved. Surfaced by the
966
+ * controller as an error frame rather than a 500 — like `UnknownModelChoiceError`
967
+ * it is a bad request, and by the time we know, the stream is already open.
968
+ */
969
+ class AttachmentError extends Error {
970
+ constructor(message) {
971
+ super(message);
972
+ this.name = 'AttachmentError';
973
+ }
974
+ }
975
+ exports.AttachmentError = AttachmentError;
976
+ /** The prompt line describing a turn's attached files. */
977
+ function attachmentManifest(attachments) {
978
+ return ('The user attached these files to the message above. They are in the ' +
979
+ 'media library — use media_asset_read with an assetId to read one whose ' +
980
+ '`readable` is true.\n' +
981
+ (0, copilot_domain_1.fenceUntrusted)('attachments', attachments));
982
+ }
983
+ /**
984
+ * The line telling the model which skills an earlier turn ran under.
985
+ *
986
+ * Titles rather than bodies, and not fenced: a skill name is written by
987
+ * somebody holding `copilot:skills:manage`, so unlike a file name it is not
988
+ * user-supplied text arriving in the prompt from outside the trust boundary.
989
+ * The skills in force *now* are stated in the system prompt; this only explains
990
+ * the shape of what is already above it.
991
+ */
992
+ function skillNote(skills) {
993
+ const names = skills.map((skill) => skill.title).join(', ');
994
+ return `(That turn ran with these skills in force: ${names}. They are not necessarily in force now.)`;
995
+ }
996
+ /** The prompt version this engine builds with, for the run record. */
997
+ exports.ENGINE_PROMPT_VERSION = system_prompt_1.SYSTEM_PROMPT_VERSION;
998
+ /** The provider-facing view of the offered tools. */
999
+ function toModelTools(tools) {
1000
+ return tools.map((tool) => ({
1001
+ name: tool.name,
1002
+ description: tool.description,
1003
+ inputSchema: tool.inputSchema
1004
+ }));
1005
+ }
1006
+ /** Input + output across a run. */
1007
+ /**
1008
+ * Folds a turn's cache counts into the run's, leaving a key absent when the
1009
+ * provider reported nothing for it.
1010
+ */
1011
+ function addCacheTokens(into, from) {
1012
+ if (from.cachedInputTokens !== undefined) {
1013
+ into.cachedInputTokens =
1014
+ (into.cachedInputTokens ?? 0) + from.cachedInputTokens;
1015
+ }
1016
+ if (from.cacheWriteInputTokens !== undefined) {
1017
+ into.cacheWriteInputTokens =
1018
+ (into.cacheWriteInputTokens ?? 0) + from.cacheWriteInputTokens;
1019
+ }
1020
+ }
1021
+ function totalTokens(usage) {
1022
+ // Cache **writes** count; cache **reads** do not. A write is billed at a
1023
+ // premium over plain input, so leaving it out would let caching make a run
1024
+ // look cheaper than it is. A read is ~a tenth of the price and is the whole
1025
+ // point of caching — charging the ceiling for it would spend the run's
1026
+ // budget on re-reading the prompt, which is exactly what caching stopped.
1027
+ return (usage.inputTokens +
1028
+ usage.outputTokens +
1029
+ (usage.cacheWriteInputTokens ?? 0));
1030
+ }
1031
+ /**
1032
+ * JSON with object keys sorted, so two calls that differ only in the order the
1033
+ * model happened to emit their arguments compare equal. Without the sort,
1034
+ * `{a,b}` and `{b,a}` are different strings and the loop guard misses the
1035
+ * repeat it exists to catch.
1036
+ */
1037
+ function stableStringify(value) {
1038
+ if (value === null || typeof value !== 'object') {
1039
+ return JSON.stringify(value) ?? 'null';
1040
+ }
1041
+ if (Array.isArray(value)) {
1042
+ return `[${value.map(stableStringify).join(',')}]`;
1043
+ }
1044
+ const entries = Object.entries(value)
1045
+ .sort(([a], [b]) => (a < b ? -1 : a > b ? 1 : 0))
1046
+ .map(([key, val]) => `${JSON.stringify(key)}:${stableStringify(val)}`);
1047
+ return `{${entries.join(',')}}`;
1048
+ }
1049
+ /**
1050
+ * An error reduced to something safe to show a user or feed back to the model
1051
+ * — never a stack, a provider payload, or anything naming internal wiring.
1052
+ */
1053
+ function userFacingMessage(error) {
1054
+ if (error instanceof Error && error.message) {
1055
+ return error.message;
1056
+ }
1057
+ return 'Something went wrong.';
1058
+ }