@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.
- package/LICENSE +21 -0
- package/README.md +7 -0
- package/dist/index.d.ts +24 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +48 -0
- package/dist/lib/chat/application/appliers-registrar.d.ts +16 -0
- package/dist/lib/chat/application/appliers-registrar.d.ts.map +1 -0
- package/dist/lib/chat/application/appliers-registrar.js +50 -0
- package/dist/lib/chat/application/capability-profile.service.d.ts +43 -0
- package/dist/lib/chat/application/capability-profile.service.d.ts.map +1 -0
- package/dist/lib/chat/application/capability-profile.service.js +75 -0
- package/dist/lib/chat/application/content-type-summary.service.d.ts +32 -0
- package/dist/lib/chat/application/content-type-summary.service.d.ts.map +1 -0
- package/dist/lib/chat/application/content-type-summary.service.js +64 -0
- package/dist/lib/chat/application/decide-proposal.service.d.ts +80 -0
- package/dist/lib/chat/application/decide-proposal.service.d.ts.map +1 -0
- package/dist/lib/chat/application/decide-proposal.service.js +112 -0
- package/dist/lib/chat/application/dto/create-run.dto.d.ts +138 -0
- package/dist/lib/chat/application/dto/create-run.dto.d.ts.map +1 -0
- package/dist/lib/chat/application/dto/create-run.dto.js +253 -0
- package/dist/lib/chat/application/dto/decide-tool-permission.dto.d.ts +20 -0
- package/dist/lib/chat/application/dto/decide-tool-permission.dto.d.ts.map +1 -0
- package/dist/lib/chat/application/dto/decide-tool-permission.dto.js +44 -0
- package/dist/lib/chat/application/dto/extend-tool-permission.dto.d.ts +18 -0
- package/dist/lib/chat/application/dto/extend-tool-permission.dto.d.ts.map +1 -0
- package/dist/lib/chat/application/dto/extend-tool-permission.dto.js +33 -0
- package/dist/lib/chat/application/dto/list-conversations-query.dto.d.ts +18 -0
- package/dist/lib/chat/application/dto/list-conversations-query.dto.d.ts.map +1 -0
- package/dist/lib/chat/application/dto/list-conversations-query.dto.js +39 -0
- package/dist/lib/chat/application/dto/list-proposals-query.dto.d.ts +15 -0
- package/dist/lib/chat/application/dto/list-proposals-query.dto.d.ts.map +1 -0
- package/dist/lib/chat/application/dto/list-proposals-query.dto.js +41 -0
- package/dist/lib/chat/application/dto/update-conversation.dto.d.ts +68 -0
- package/dist/lib/chat/application/dto/update-conversation.dto.d.ts.map +1 -0
- package/dist/lib/chat/application/dto/update-conversation.dto.js +115 -0
- package/dist/lib/chat/application/proposal-applier.registry.d.ts +27 -0
- package/dist/lib/chat/application/proposal-applier.registry.d.ts.map +1 -0
- package/dist/lib/chat/application/proposal-applier.registry.js +62 -0
- package/dist/lib/chat/application/run-engine.service.d.ts +243 -0
- package/dist/lib/chat/application/run-engine.service.d.ts.map +1 -0
- package/dist/lib/chat/application/run-engine.service.js +1058 -0
- package/dist/lib/chat/application/summarize-tool-output.d.ts +15 -0
- package/dist/lib/chat/application/summarize-tool-output.d.ts.map +1 -0
- package/dist/lib/chat/application/summarize-tool-output.js +61 -0
- package/dist/lib/chat/application/system-prompt.d.ts +81 -0
- package/dist/lib/chat/application/system-prompt.d.ts.map +1 -0
- package/dist/lib/chat/application/system-prompt.js +477 -0
- package/dist/lib/chat/application/tool-permission.broker.d.ts +118 -0
- package/dist/lib/chat/application/tool-permission.broker.d.ts.map +1 -0
- package/dist/lib/chat/application/tool-permission.broker.js +221 -0
- package/dist/lib/chat/http/controllers/create-run.controller.d.ts +30 -0
- package/dist/lib/chat/http/controllers/create-run.controller.d.ts.map +1 -0
- package/dist/lib/chat/http/controllers/create-run.controller.js +159 -0
- package/dist/lib/chat/http/controllers/get-conversation.controller.d.ts +22 -0
- package/dist/lib/chat/http/controllers/get-conversation.controller.d.ts.map +1 -0
- package/dist/lib/chat/http/controllers/get-conversation.controller.js +50 -0
- package/dist/lib/chat/http/controllers/list-conversations.controller.d.ts +20 -0
- package/dist/lib/chat/http/controllers/list-conversations.controller.d.ts.map +1 -0
- package/dist/lib/chat/http/controllers/list-conversations.controller.js +47 -0
- package/dist/lib/chat/http/controllers/list-models.controller.d.ts +35 -0
- package/dist/lib/chat/http/controllers/list-models.controller.d.ts.map +1 -0
- package/dist/lib/chat/http/controllers/list-models.controller.js +48 -0
- package/dist/lib/chat/http/controllers/proposals.controller.d.ts +43 -0
- package/dist/lib/chat/http/controllers/proposals.controller.d.ts.map +1 -0
- package/dist/lib/chat/http/controllers/proposals.controller.js +98 -0
- package/dist/lib/chat/http/controllers/tool-permission.controller.d.ts +57 -0
- package/dist/lib/chat/http/controllers/tool-permission.controller.d.ts.map +1 -0
- package/dist/lib/chat/http/controllers/tool-permission.controller.js +116 -0
- package/dist/lib/chat/http/controllers/update-conversation.controller.d.ts +37 -0
- package/dist/lib/chat/http/controllers/update-conversation.controller.d.ts.map +1 -0
- package/dist/lib/chat/http/controllers/update-conversation.controller.js +108 -0
- package/dist/lib/chat/http/sse-stream.d.ts +41 -0
- package/dist/lib/chat/http/sse-stream.d.ts.map +1 -0
- package/dist/lib/chat/http/sse-stream.js +95 -0
- package/dist/lib/chat/infrastructure/persistence/conversation.repository.d.ts +159 -0
- package/dist/lib/chat/infrastructure/persistence/conversation.repository.d.ts.map +1 -0
- package/dist/lib/chat/infrastructure/persistence/conversation.repository.js +249 -0
- package/dist/lib/chat/infrastructure/persistence/derive-title.d.ts +13 -0
- package/dist/lib/chat/infrastructure/persistence/derive-title.d.ts.map +1 -0
- package/dist/lib/chat/infrastructure/persistence/derive-title.js +35 -0
- package/dist/lib/chat/infrastructure/persistence/proposal.repository.d.ts +86 -0
- package/dist/lib/chat/infrastructure/persistence/proposal.repository.d.ts.map +1 -0
- package/dist/lib/chat/infrastructure/persistence/proposal.repository.js +152 -0
- package/dist/lib/chat/infrastructure/schema/conversations.d.ts +189 -0
- package/dist/lib/chat/infrastructure/schema/conversations.d.ts.map +1 -0
- package/dist/lib/chat/infrastructure/schema/conversations.js +100 -0
- package/dist/lib/chat/infrastructure/schema/external-refs.d.ts +67 -0
- package/dist/lib/chat/infrastructure/schema/external-refs.d.ts.map +1 -0
- package/dist/lib/chat/infrastructure/schema/external-refs.js +28 -0
- package/dist/lib/chat/infrastructure/schema/index.d.ts +5 -0
- package/dist/lib/chat/infrastructure/schema/index.d.ts.map +1 -0
- package/dist/lib/chat/infrastructure/schema/index.js +12 -0
- package/dist/lib/chat/infrastructure/schema/messages.d.ts +264 -0
- package/dist/lib/chat/infrastructure/schema/messages.d.ts.map +1 -0
- package/dist/lib/chat/infrastructure/schema/messages.js +96 -0
- package/dist/lib/chat/infrastructure/schema/proposals.d.ts +328 -0
- package/dist/lib/chat/infrastructure/schema/proposals.d.ts.map +1 -0
- package/dist/lib/chat/infrastructure/schema/proposals.js +87 -0
- package/dist/lib/chat/infrastructure/schema/tool-calls.d.ts +203 -0
- package/dist/lib/chat/infrastructure/schema/tool-calls.d.ts.map +1 -0
- package/dist/lib/chat/infrastructure/schema/tool-calls.js +50 -0
- package/dist/lib/copilot.module.d.ts +30 -0
- package/dist/lib/copilot.module.d.ts.map +1 -0
- package/dist/lib/copilot.module.js +146 -0
- package/dist/lib/copilot.tokens.d.ts +34 -0
- package/dist/lib/copilot.tokens.d.ts.map +1 -0
- package/dist/lib/copilot.tokens.js +38 -0
- package/dist/lib/infrastructure/model-registry.d.ts +22 -0
- package/dist/lib/infrastructure/model-registry.d.ts.map +1 -0
- package/dist/lib/infrastructure/model-registry.js +54 -0
- package/dist/lib/skills/application/dto/create-skill.dto.d.ts +36 -0
- package/dist/lib/skills/application/dto/create-skill.dto.d.ts.map +1 -0
- package/dist/lib/skills/application/dto/create-skill.dto.js +106 -0
- package/dist/lib/skills/application/dto/update-skill.dto.d.ts +28 -0
- package/dist/lib/skills/application/dto/update-skill.dto.d.ts.map +1 -0
- package/dist/lib/skills/application/dto/update-skill.dto.js +96 -0
- package/dist/lib/skills/application/skill-catalog.service.d.ts +97 -0
- package/dist/lib/skills/application/skill-catalog.service.d.ts.map +1 -0
- package/dist/lib/skills/application/skill-catalog.service.js +157 -0
- package/dist/lib/skills/http/controllers/list-skills.controller.d.ts +19 -0
- package/dist/lib/skills/http/controllers/list-skills.controller.d.ts.map +1 -0
- package/dist/lib/skills/http/controllers/list-skills.controller.js +50 -0
- package/dist/lib/skills/http/controllers/manage-skills.controller.d.ts +64 -0
- package/dist/lib/skills/http/controllers/manage-skills.controller.d.ts.map +1 -0
- package/dist/lib/skills/http/controllers/manage-skills.controller.js +182 -0
- package/dist/lib/skills/infrastructure/persistence/skill.repository.d.ts +78 -0
- package/dist/lib/skills/infrastructure/persistence/skill.repository.d.ts.map +1 -0
- package/dist/lib/skills/infrastructure/persistence/skill.repository.js +116 -0
- package/dist/lib/skills/infrastructure/schema/index.d.ts +2 -0
- package/dist/lib/skills/infrastructure/schema/index.d.ts.map +1 -0
- package/dist/lib/skills/infrastructure/schema/index.js +6 -0
- package/dist/lib/skills/infrastructure/schema/skills.d.ts +212 -0
- package/dist/lib/skills/infrastructure/schema/skills.d.ts.map +1 -0
- package/dist/lib/skills/infrastructure/schema/skills.js +78 -0
- package/dist/lib/types/copilot-config.d.ts +55 -0
- package/dist/lib/types/copilot-config.d.ts.map +1 -0
- package/dist/lib/types/copilot-config.js +2 -0
- package/dist/lib/utils/copilot-plugin.d.ts +66 -0
- package/dist/lib/utils/copilot-plugin.d.ts.map +1 -0
- package/dist/lib/utils/copilot-plugin.js +87 -0
- package/migrations/0000_copilot_chat.sql +48 -0
- package/migrations/0001_proposals.sql +34 -0
- package/migrations/0002_drop_workspace_policies.sql +1 -0
- package/migrations/0003_conversation_allowed_tools.sql +1 -0
- package/migrations/0004_message_attachments.sql +1 -0
- package/migrations/0005_skills.sql +19 -0
- package/migrations/0006_conversation_model_choice.sql +1 -0
- package/migrations/meta/0000_snapshot.json +390 -0
- package/migrations/meta/0001_snapshot.json +657 -0
- package/migrations/meta/0002_snapshot.json +610 -0
- package/migrations/meta/0003_snapshot.json +617 -0
- package/migrations/meta/0004_snapshot.json +623 -0
- package/migrations/meta/0005_snapshot.json +771 -0
- package/migrations/meta/0006_snapshot.json +777 -0
- package/migrations/meta/_journal.json +55 -0
- 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
|
+
}
|