@k2b/cloud 0.26.0 → 0.28.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/package.json +3 -3
- package/src/_internal/define-app.ts +8 -1
- package/src/_internal/process-identity.ts +7 -1
- package/src/_internal/registry-validation.ts +3 -0
- package/src/_internal/runtime-context.ts +1 -0
- package/src/ai/admin.ts +1 -0
- package/src/ai/browser-code-contracts.ts +46 -64
- package/src/ai/browser.ts +9 -1
- package/src/ai/capabilities.ts +82 -22
- package/src/ai/chat/blocks.tsx +16 -7
- package/src/ai/chat/builtin-tools.tsx +31 -23
- package/src/ai/chat/file-tools.tsx +4 -1
- package/src/ai/chat/live-turn.browser-harness.tsx +15 -5
- package/src/ai/chat/message-actions.tsx +123 -101
- package/src/ai/chat/message-utils.ts +30 -1
- package/src/ai/chat/messages.ts +206 -2
- package/src/ai/chat/presentation.tsx +94 -28
- package/src/ai/chat/tool-groups.ts +1 -1
- package/src/ai/chat/turn-error.ts +21 -0
- package/src/ai/chat/turn-view.tsx +102 -16
- package/src/ai/chat/user-message.tsx +19 -14
- package/src/ai/chat/visual-tools.tsx +1 -1
- package/src/ai/client/controller.ts +83 -37
- package/src/ai/client/file-source.ts +20 -3
- package/src/ai/client/projection.ts +7 -2
- package/src/ai/code-mode-skill.ts +32 -36
- package/src/ai/code-runtime-tools.ts +14 -1
- package/src/ai/code-source-contracts.ts +56 -6
- package/src/ai/code-source-tools.ts +10 -3
- package/src/ai/credentials.ts +17 -3
- package/src/ai/data-analysis-skill.ts +3 -3
- package/src/ai/default-tools.ts +15 -16
- package/src/ai/executor.ts +281 -141
- package/src/ai/file-context.ts +14 -2
- package/src/ai/file-tools.ts +17 -3
- package/src/ai/files-store.ts +134 -11
- package/src/ai/grids-skill.ts +2 -2
- package/src/ai/index.ts +9 -0
- package/src/ai/memories.ts +14 -0
- package/src/ai/migrate.ts +125 -0
- package/src/ai/model-request-settings.ts +98 -0
- package/src/ai/open-tool-calls.ts +87 -0
- package/src/ai/protocol.ts +6 -0
- package/src/ai/provider.ts +7 -1
- package/src/ai/quota-provider.ts +2 -2
- package/src/ai/request-headers.ts +117 -0
- package/src/ai/routes.ts +40 -6
- package/src/ai/run-timeout.ts +4 -5
- package/src/ai/runtime.ts +9 -1
- package/src/ai/settings.ts +19 -2
- package/src/ai/skill-seeds.ts +35 -7
- package/src/ai/skills.ts +26 -0
- package/src/ai/solid.ts +1 -1
- package/src/ai/store.ts +155 -71
- package/src/ai/stream.ts +180 -37
- package/src/ai/structured.ts +20 -5
- package/src/ai/system-prompt.ts +8 -0
- package/src/ai/tool-call-names.ts +45 -0
- package/src/ai/turn-failure.ts +100 -0
- package/src/ai/turn-policy.ts +248 -0
- package/src/ai/types.ts +52 -4
- package/src/api/admin-ai-quotas.ts +36 -1
- package/src/api/admin-core-settings.ts +16 -23
- package/src/api/admin-outgoing-mail.ts +242 -0
- package/src/api/index.ts +2 -0
- package/src/browser/FileChooser.tsx +11 -0
- package/src/browser/file-chooser-messages.ts +2 -0
- package/src/cli/admin/ai-quotas.ts +70 -1
- package/src/cli/admin/index.ts +8 -0
- package/src/cli/admin/notifications.ts +6 -0
- package/src/cli/admin/outgoing-mail.ts +215 -0
- package/src/contracts/app.ts +2 -0
- package/src/contracts/index.ts +1 -0
- package/src/contracts/outgoing-mail.ts +265 -0
- package/src/contracts/registry.ts +2 -0
- package/src/services/help/store.ts +2 -1
- package/src/services/index.ts +13 -0
- package/src/services/notifications/batches.ts +139 -79
- package/src/services/notifications/channels.ts +33 -13
- package/src/services/notifications/dispatcher.ts +57 -5
- package/src/services/notifications/email-frame.fixture.html +51 -0
- package/src/services/notifications/{email.ts → email-frame.ts} +12 -45
- package/src/services/notifications/email-mail.ts +101 -0
- package/src/services/notifications/index.ts +49 -36
- package/src/services/notifications/observability.ts +9 -1
- package/src/services/notifications/platform.ts +1 -1
- package/src/services/notifications/runtime.ts +9 -3
- package/src/services/outgoing-mail/admin.ts +84 -0
- package/src/services/outgoing-mail/attachments.ts +135 -0
- package/src/services/outgoing-mail/bulk.ts +11 -0
- package/src/services/outgoing-mail/dispatcher.ts +231 -0
- package/src/services/outgoing-mail/drain.ts +60 -0
- package/src/services/outgoing-mail/enqueue.ts +180 -0
- package/src/services/outgoing-mail/index.ts +112 -0
- package/src/services/outgoing-mail/message.ts +14 -0
- package/src/services/outgoing-mail/messages.ts +431 -0
- package/src/services/outgoing-mail/retention.ts +48 -0
- package/src/services/outgoing-mail/runtime.ts +72 -0
- package/src/services/outgoing-mail/send.ts +130 -0
- package/src/services/outgoing-mail/store.ts +397 -0
- package/src/services/outgoing-mail/sync.ts +39 -0
- package/src/services/outgoing-mail/test-send.ts +40 -0
- package/src/services/outgoing-mail/transport.ts +17 -0
- package/src/services/pdf/markdown.ts +22 -4
- package/src/services/postgres.ts +15 -0
- package/src/services/settings/core-settings.ts +19 -38
- package/src/services/settings/store.ts +5 -1
- package/src/shared/ai-model-request-settings.ts +21 -0
- package/src/shared/ai-platform-prompt.ts +49 -5
- package/src/shared/ai-request-options.ts +185 -0
- package/src/shared/markdown/extensions/links.ts +28 -20
- package/src/shared/markdown/index.ts +14 -5
- package/src/shared/markdown/shared.ts +0 -7
- package/src/ssr/GlobalAnnouncements.island.tsx +1 -1
- package/src/ssr/admin-navigation.ts +1 -1
- package/src/ssr/platform-messages.ts +3 -1
- package/src/ssr/workspace-navigation.ts +7 -1
- package/src/styles/effects.css +15 -17
- package/src/styles/tokens.css +2 -0
- package/src/styles/utilities-markdown-editor.css +4 -39
- package/src/styles/utilities-markdown-table.css +14 -17
|
@@ -0,0 +1,248 @@
|
|
|
1
|
+
import type { Provider, Tool, ToolResolver } from "@k2b/nessi";
|
|
2
|
+
import type { AiToolBlockStatus } from "./protocol";
|
|
3
|
+
import { AiTurnFailure } from "./turn-failure";
|
|
4
|
+
|
|
5
|
+
/** Calls that only find or load other tools. */
|
|
6
|
+
const DISCOVERY_TOOL_NAMES = new Set(["search_tools", "list_apps", "load_tools"]);
|
|
7
|
+
/** Discovery calls in a row, without a completed working step, that earn a hint: three app lookups of search plus load. */
|
|
8
|
+
const DISCOVERY_STREAK_LIMIT = 6;
|
|
9
|
+
/** The last tenth of a turn's run time limit is kept for the answer. */
|
|
10
|
+
const FINAL_ANSWER_BUDGET_SHARE = 0.1;
|
|
11
|
+
|
|
12
|
+
export type AiTurnFinalReason = "tool_rounds" | "run_time" | "loop";
|
|
13
|
+
export type AiTurnHint = "repeated_failure" | "discovery_loop";
|
|
14
|
+
export type AiTurnPolicyDecision = { kind: "hint"; hints: AiTurnHint[] } | { kind: "final_answer"; reason: AiTurnFinalReason };
|
|
15
|
+
|
|
16
|
+
/** A finished tool call under the name the model called it by. */
|
|
17
|
+
export type AiTurnPolicyToolCall = { name: string; args?: unknown; status: AiToolBlockStatus; result?: unknown };
|
|
18
|
+
|
|
19
|
+
const FINAL_ANSWER_CAUSE: Record<AiTurnFinalReason, string> = {
|
|
20
|
+
tool_rounds: "The configured tool-round budget has been reached, so no more tools are available in this turn.",
|
|
21
|
+
run_time: "The run time limit of this turn is almost reached, so no more tools are available in this turn.",
|
|
22
|
+
loop: "This turn kept repeating steps that made no progress, so no more tools are available in this turn.",
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
const FINAL_ANSWER_CLOSE: Record<AiTurnFinalReason, string> = {
|
|
26
|
+
tool_rounds: "",
|
|
27
|
+
run_time: " Say which parts are still open; the user can continue the task with a new message.",
|
|
28
|
+
loop: " Say what blocked you and what the user can do about it.",
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const finalAnswerPrompt = (reason: AiTurnFinalReason) =>
|
|
32
|
+
`# Final response
|
|
33
|
+
${FINAL_ANSWER_CAUSE[reason]} Answer the user's request now with the best result supported by the evidence already gathered. State any material uncertainty or incomplete part clearly.${FINAL_ANSWER_CLOSE[reason]}`;
|
|
34
|
+
|
|
35
|
+
const listFormat = new Intl.ListFormat("en", { type: "conjunction" });
|
|
36
|
+
|
|
37
|
+
/** Names a hint may repeat: only tools the model was offered, never a name it made up. */
|
|
38
|
+
const offeredNames = (names: readonly string[], offered: ReadonlySet<string>) => {
|
|
39
|
+
const known = names.filter((name) => offered.has(name));
|
|
40
|
+
return listFormat.format(known.length < names.length ? [...known, "a tool that is not available"] : known);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const hintPrompt = (hints: { repeatedFailures: string[]; discoveryStreak: number | null }, offered: ReadonlySet<string>) =>
|
|
44
|
+
[
|
|
45
|
+
"# Turn check",
|
|
46
|
+
hints.repeatedFailures.length > 0
|
|
47
|
+
? `Calls to ${offeredNames(hints.repeatedFailures, offered)} failed twice with the same input. Do not repeat them with that input. Change your approach, or tell the user what blocks you.`
|
|
48
|
+
: undefined,
|
|
49
|
+
hints.discoveryStreak !== null
|
|
50
|
+
? `You searched for or loaded tools ${hints.discoveryStreak} times in a row without completing another step. Stop searching. Use the tools you already have, load the ones a search found, or tell the user what is not available in this turn.`
|
|
51
|
+
: undefined,
|
|
52
|
+
]
|
|
53
|
+
.filter(Boolean)
|
|
54
|
+
.join("\n");
|
|
55
|
+
|
|
56
|
+
/** A completed load_tools call that made at least one more tool callable. */
|
|
57
|
+
const loadedTools = ({ name, status, result }: AiTurnPolicyToolCall) =>
|
|
58
|
+
name === "load_tools" &&
|
|
59
|
+
status === "completed" &&
|
|
60
|
+
typeof result === "object" &&
|
|
61
|
+
result !== null &&
|
|
62
|
+
"loaded" in result &&
|
|
63
|
+
Array.isArray(result.loaded) &&
|
|
64
|
+
result.loaded.length > 0;
|
|
65
|
+
|
|
66
|
+
const canonicalJson = (value: unknown): string => {
|
|
67
|
+
if (Array.isArray(value)) return `[${value.map(canonicalJson).join(",")}]`;
|
|
68
|
+
if (value && typeof value === "object")
|
|
69
|
+
return `{${Object.entries(value)
|
|
70
|
+
.filter(([, item]) => item !== undefined)
|
|
71
|
+
.sort(([left], [right]) => (left < right ? -1 : left > right ? 1 : 0))
|
|
72
|
+
.map(([key, item]) => `${JSON.stringify(key)}:${canonicalJson(item)}`)
|
|
73
|
+
.join(",")}}`;
|
|
74
|
+
return JSON.stringify(value) ?? "null";
|
|
75
|
+
};
|
|
76
|
+
|
|
77
|
+
const withPrompt = (systemPrompt: string | undefined, suffix: string | null) =>
|
|
78
|
+
suffix ? `${systemPrompt ?? ""}\n\n${suffix}`.trim() : systemPrompt;
|
|
79
|
+
|
|
80
|
+
/**
|
|
81
|
+
* Keeps a chat turn from ending without an answer (#530).
|
|
82
|
+
*
|
|
83
|
+
* The policy is checked before every model call:
|
|
84
|
+
* - the same call (tool name and input) failing twice, or six discovery calls
|
|
85
|
+
* in a row without a completed working step, adds a one-time hint to the next
|
|
86
|
+
* model call;
|
|
87
|
+
* - either pattern after its hint, a positive `maxToolRounds` that is used up,
|
|
88
|
+
* or the last tenth of the run time limit ends tool use: the next model call
|
|
89
|
+
* gets no tools and a prompt to answer with what the turn has. If that call
|
|
90
|
+
* still asks for tools, the turn fails instead of looping without them.
|
|
91
|
+
*
|
|
92
|
+
* Rejected approvals are user decisions, not failures, and loading tools a
|
|
93
|
+
* search found does not continue a discovery loop. A steering message starts
|
|
94
|
+
* the loop checks over. The deadline abort stays as the backstop when a single
|
|
95
|
+
* model or tool call outlasts the reserve. Calls a resumed attempt already
|
|
96
|
+
* finished seed the counts without a hint of their own; a hint the earlier
|
|
97
|
+
* attempt gave is not remembered.
|
|
98
|
+
*/
|
|
99
|
+
export const applyAiTurnPolicy = (input: {
|
|
100
|
+
provider: Provider;
|
|
101
|
+
tools: Tool[] | ToolResolver;
|
|
102
|
+
maxToolRounds?: number;
|
|
103
|
+
issuedToolRounds: number;
|
|
104
|
+
completedToolRounds: number;
|
|
105
|
+
/** Epoch milliseconds when the run time limit ends; null without a limit. */
|
|
106
|
+
deadline: number | null;
|
|
107
|
+
runBudgetMs: number | null;
|
|
108
|
+
/** Tool calls this turn finished before this attempt and after its last steering message, oldest first. */
|
|
109
|
+
finishedToolCalls?: readonly AiTurnPolicyToolCall[];
|
|
110
|
+
onDecision?: (decision: AiTurnPolicyDecision) => void;
|
|
111
|
+
now?: () => number;
|
|
112
|
+
}): {
|
|
113
|
+
provider: Provider;
|
|
114
|
+
tools: ToolResolver;
|
|
115
|
+
maxTurns?: number;
|
|
116
|
+
noteToolRound: () => void;
|
|
117
|
+
noteToolCall: (call: AiTurnPolicyToolCall) => void;
|
|
118
|
+
noteSteering: () => void;
|
|
119
|
+
} => {
|
|
120
|
+
const now = input.now ?? Date.now;
|
|
121
|
+
const limit = Math.floor(input.maxToolRounds ?? 0);
|
|
122
|
+
const issuedAtStart = Math.max(0, Math.floor(input.issuedToolRounds));
|
|
123
|
+
let completed = Math.max(0, Math.floor(input.completedToolRounds));
|
|
124
|
+
// nessi resolves the tools once more to finish a round an earlier attempt left open.
|
|
125
|
+
let resumingRound = issuedAtStart > completed;
|
|
126
|
+
const reserveMs = input.runBudgetMs && input.runBudgetMs > 0 ? input.runBudgetMs * FINAL_ANSWER_BUDGET_SHARE : 0;
|
|
127
|
+
|
|
128
|
+
const failures = new Map<string, number>();
|
|
129
|
+
let repeatedFailures = new Set<string>();
|
|
130
|
+
let discoveryStreak = 0;
|
|
131
|
+
// A discovery call since the last check that made no new tool callable.
|
|
132
|
+
let discoveredSinceCheck = false;
|
|
133
|
+
const hinted = new Set<AiTurnHint>();
|
|
134
|
+
let finalReason: AiTurnFinalReason | null = null;
|
|
135
|
+
let completedAtFinal = 0;
|
|
136
|
+
let pendingHint: string | null = null;
|
|
137
|
+
let offered = new Set<string>();
|
|
138
|
+
|
|
139
|
+
const noteToolCall = (call: AiTurnPolicyToolCall) => {
|
|
140
|
+
const { name, args, status } = call;
|
|
141
|
+
if (status !== "completed" && status !== "failed") return;
|
|
142
|
+
if (status === "failed") {
|
|
143
|
+
const key = `${name}\u0000${canonicalJson(args)}`;
|
|
144
|
+
const count = (failures.get(key) ?? 0) + 1;
|
|
145
|
+
failures.set(key, count);
|
|
146
|
+
if (count >= 2) repeatedFailures.add(name);
|
|
147
|
+
}
|
|
148
|
+
if (DISCOVERY_TOOL_NAMES.has(name)) {
|
|
149
|
+
discoveryStreak += 1;
|
|
150
|
+
if (!loadedTools(call)) discoveredSinceCheck = true;
|
|
151
|
+
} else if (status === "completed") {
|
|
152
|
+
discoveryStreak = 0;
|
|
153
|
+
}
|
|
154
|
+
};
|
|
155
|
+
for (const call of input.finishedToolCalls ?? []) noteToolCall(call);
|
|
156
|
+
repeatedFailures = new Set();
|
|
157
|
+
discoveredSinceCheck = false;
|
|
158
|
+
|
|
159
|
+
const reserveReached = () => input.deadline !== null && reserveMs > 0 && input.deadline - now() <= reserveMs;
|
|
160
|
+
const endToolUse = (reason: AiTurnFinalReason) => {
|
|
161
|
+
finalReason = reason;
|
|
162
|
+
completedAtFinal = completed;
|
|
163
|
+
pendingHint = null;
|
|
164
|
+
input.onDecision?.({ kind: "final_answer", reason });
|
|
165
|
+
};
|
|
166
|
+
|
|
167
|
+
const check = () => {
|
|
168
|
+
if (finalReason) return;
|
|
169
|
+
if (limit > 0 && completed >= limit) endToolUse("tool_rounds");
|
|
170
|
+
else if (reserveReached()) endToolUse("run_time");
|
|
171
|
+
else {
|
|
172
|
+
const triggered: AiTurnHint[] = [];
|
|
173
|
+
if (repeatedFailures.size > 0) triggered.push("repeated_failure");
|
|
174
|
+
if (discoveredSinceCheck && discoveryStreak >= DISCOVERY_STREAK_LIMIT) triggered.push("discovery_loop");
|
|
175
|
+
if (triggered.some((hint) => hinted.has(hint))) endToolUse("loop");
|
|
176
|
+
else if (triggered.length > 0) {
|
|
177
|
+
for (const hint of triggered) hinted.add(hint);
|
|
178
|
+
pendingHint = hintPrompt(
|
|
179
|
+
{
|
|
180
|
+
repeatedFailures: triggered.includes("repeated_failure") ? [...repeatedFailures] : [],
|
|
181
|
+
discoveryStreak: triggered.includes("discovery_loop") ? discoveryStreak : null,
|
|
182
|
+
},
|
|
183
|
+
offered,
|
|
184
|
+
);
|
|
185
|
+
input.onDecision?.({ kind: "hint", hints: triggered });
|
|
186
|
+
}
|
|
187
|
+
}
|
|
188
|
+
repeatedFailures = new Set();
|
|
189
|
+
discoveredSinceCheck = false;
|
|
190
|
+
};
|
|
191
|
+
|
|
192
|
+
const tools: ToolResolver = async () => {
|
|
193
|
+
if (resumingRound) resumingRound = false;
|
|
194
|
+
else check();
|
|
195
|
+
if (finalReason) {
|
|
196
|
+
if (completed > completedAtFinal) throw new AiTurnFailure("step_limit", "The model did not produce a final answer without tools.");
|
|
197
|
+
return [];
|
|
198
|
+
}
|
|
199
|
+
const resolved = typeof input.tools === "function" ? await input.tools() : input.tools;
|
|
200
|
+
offered = new Set(resolved.map((tool) => tool.def.name));
|
|
201
|
+
return resolved;
|
|
202
|
+
};
|
|
203
|
+
const provider: Provider = {
|
|
204
|
+
name: input.provider.name,
|
|
205
|
+
family: input.provider.family,
|
|
206
|
+
model: input.provider.model,
|
|
207
|
+
contextWindow: input.provider.contextWindow,
|
|
208
|
+
capabilities: input.provider.capabilities,
|
|
209
|
+
complete: (request) => input.provider.complete(request),
|
|
210
|
+
stream: async function* (request) {
|
|
211
|
+
// Compaction may run between the tool check and this call; the reserve still holds.
|
|
212
|
+
if (!finalReason && reserveReached()) endToolUse("run_time");
|
|
213
|
+
let overflowed = false;
|
|
214
|
+
for await (const event of input.provider.stream({
|
|
215
|
+
...request,
|
|
216
|
+
tools: finalReason ? [] : request.tools,
|
|
217
|
+
systemPrompt: withPrompt(request.systemPrompt, finalReason ? finalAnswerPrompt(finalReason) : pendingHint),
|
|
218
|
+
})) {
|
|
219
|
+
if (event.type === "issue" && event.issue.kind === "provider_error" && event.issue.contextOverflow) overflowed = true;
|
|
220
|
+
yield event;
|
|
221
|
+
}
|
|
222
|
+
// nessi compacts an overflowed request and sends it again; the hint goes with it.
|
|
223
|
+
if (!overflowed) pendingHint = null;
|
|
224
|
+
},
|
|
225
|
+
};
|
|
226
|
+
|
|
227
|
+
return {
|
|
228
|
+
provider,
|
|
229
|
+
tools,
|
|
230
|
+
// Nessi checks this before provider calls. The extra round is the tool-free
|
|
231
|
+
// synthesis call after the last allowed tool-using round.
|
|
232
|
+
...(limit > 0 ? { maxTurns: Math.max(1, limit - issuedAtStart + 1) } : {}),
|
|
233
|
+
noteToolRound: () => {
|
|
234
|
+
completed += 1;
|
|
235
|
+
},
|
|
236
|
+
noteToolCall,
|
|
237
|
+
noteSteering: () => {
|
|
238
|
+
// New user input: the loop checks start over, and a loop's final answer may use tools again.
|
|
239
|
+
failures.clear();
|
|
240
|
+
repeatedFailures = new Set();
|
|
241
|
+
discoveryStreak = 0;
|
|
242
|
+
discoveredSinceCheck = false;
|
|
243
|
+
hinted.clear();
|
|
244
|
+
pendingHint = null;
|
|
245
|
+
finalReason = null;
|
|
246
|
+
},
|
|
247
|
+
};
|
|
248
|
+
};
|
package/src/ai/types.ts
CHANGED
|
@@ -45,6 +45,8 @@ export type AiModelProfile = {
|
|
|
45
45
|
contextWindow?: number;
|
|
46
46
|
temperature?: number;
|
|
47
47
|
maxOutputTokens?: number;
|
|
48
|
+
reasoningEffort?: string;
|
|
49
|
+
extraBody?: Record<string, unknown>;
|
|
48
50
|
/** Maximum deferred tools retained per conversation. Missing or <= 0 keeps all loaded tools. */
|
|
49
51
|
maxLoadedTools?: number;
|
|
50
52
|
/** Tool-using model rounds per chat turn. Missing or <= 0 is unlimited. */
|
|
@@ -156,6 +158,8 @@ export type AiConversation = {
|
|
|
156
158
|
runStatus: AiConversationRunStatus;
|
|
157
159
|
/** Error from the latest turn when `runStatus` is `failed`. */
|
|
158
160
|
runError: string | null;
|
|
161
|
+
/** Public ID of the latest turn, which `runStatus` and `runError` describe; null before the first turn. */
|
|
162
|
+
runTurnId?: string | null;
|
|
159
163
|
unreadCompletion: boolean;
|
|
160
164
|
/** Optional shared project context; the conversation itself remains private to its owner. */
|
|
161
165
|
projectId: string | null;
|
|
@@ -297,6 +301,8 @@ export type AiStoredMessage = {
|
|
|
297
301
|
};
|
|
298
302
|
toolPresentations?: Record<string, AiToolPresentation>;
|
|
299
303
|
toolOutcomes?: Record<string, "rejected" | "approved">;
|
|
304
|
+
/** Why the turn failed, on the last message of its loop. The chat words it in the reader's language. */
|
|
305
|
+
turnError?: AiTurnError;
|
|
300
306
|
} | null;
|
|
301
307
|
/** Private owner feedback for this rendered assistant response. Never enters model context. */
|
|
302
308
|
feedback?: AiMessageFeedback | null;
|
|
@@ -317,6 +323,23 @@ export type AiConversationTimelineEntry = {
|
|
|
317
323
|
createdAt: string;
|
|
318
324
|
};
|
|
319
325
|
|
|
326
|
+
/**
|
|
327
|
+
* Why a turn failed, as a stable code. `limitMinutes` comes with `time_limit` when the turn had a run time limit.
|
|
328
|
+
* Codes are never translated; clients word them in the reader's language.
|
|
329
|
+
*/
|
|
330
|
+
export type AiTurnErrorCode =
|
|
331
|
+
| "model_unavailable"
|
|
332
|
+
| "quota_exhausted"
|
|
333
|
+
| "context_full"
|
|
334
|
+
| "time_limit"
|
|
335
|
+
| "step_limit"
|
|
336
|
+
| "wait_expired"
|
|
337
|
+
| "interrupted"
|
|
338
|
+
| "not_allowed"
|
|
339
|
+
| "failed";
|
|
340
|
+
|
|
341
|
+
export type AiTurnError = { code: AiTurnErrorCode; limitMinutes?: number };
|
|
342
|
+
|
|
320
343
|
export type AiTurnStatus = "queued" | "running" | "waiting_for_action" | "completed" | "failed" | "aborted";
|
|
321
344
|
|
|
322
345
|
export type AiTurnSteerStatus = "pending" | "consumed" | "discarded";
|
|
@@ -437,10 +460,10 @@ export type AiClientToolId =
|
|
|
437
460
|
| "code_run"
|
|
438
461
|
| "code_action"
|
|
439
462
|
| "code_inspect"
|
|
440
|
-
| "code_interact"
|
|
441
463
|
| "code_stop"
|
|
442
464
|
| "code_open"
|
|
443
465
|
| "code_present"
|
|
466
|
+
| "code_check"
|
|
444
467
|
| "code_export"
|
|
445
468
|
| "code_secret";
|
|
446
469
|
|
|
@@ -510,7 +533,14 @@ export type AiConversationResourceOccurrence = AiConversationResourceRef & {
|
|
|
510
533
|
chat: Pick<AiConversation, "shortId" | "title" | "updatedAt">;
|
|
511
534
|
};
|
|
512
535
|
|
|
513
|
-
|
|
536
|
+
/**
|
|
537
|
+
* - `result`: something the assistant delivered with `present` (a file, `path` set), `code_open` (an app, `ref` set) or
|
|
538
|
+
* `code_present` (a chat visualization, neither set; `sourceCallId` names it).
|
|
539
|
+
* - `web`: a page read with `web_extract`; `activity`: a web search, one entry per query.
|
|
540
|
+
* - `resource`: a Cloud resource a tool call read, or one indexed as Project context without a call.
|
|
541
|
+
* - `file`: a conversation file.
|
|
542
|
+
*/
|
|
543
|
+
export type AiConversationSourceKind = "result" | "web" | "file" | "resource" | "activity";
|
|
514
544
|
|
|
515
545
|
export type AiConversationSource = {
|
|
516
546
|
kind: AiConversationSourceKind;
|
|
@@ -528,15 +558,23 @@ export type AiConversationSource = {
|
|
|
528
558
|
lastSeenAt: string;
|
|
529
559
|
sourceTurnId: string | null;
|
|
530
560
|
sourceCallId: string | null;
|
|
561
|
+
/**
|
|
562
|
+
* Chat position of the source: the assistant message that holds `sourceCallId`, otherwise the start of its turn.
|
|
563
|
+
* Use it as the `message` link target; null when the turn is gone.
|
|
564
|
+
*/
|
|
565
|
+
sourceMessageSeq: number | null;
|
|
531
566
|
};
|
|
532
567
|
|
|
533
568
|
export type AiConversationSourceObservation = {
|
|
534
|
-
kind: "web" | "activity";
|
|
569
|
+
kind: "result" | "web" | "activity";
|
|
535
570
|
key: string;
|
|
536
571
|
title: string;
|
|
572
|
+
/** For `result`, the delivered description; a new delivery always replaces it, also with none. */
|
|
537
573
|
preview?: string;
|
|
538
574
|
icon?: string;
|
|
539
575
|
href?: string;
|
|
576
|
+
/** The delivered Cloud resource of a `result`, for example an opened app. */
|
|
577
|
+
ref?: CloudResourceRef;
|
|
540
578
|
};
|
|
541
579
|
|
|
542
580
|
export type AiConversationFileSnapshotEntry = {
|
|
@@ -571,6 +609,9 @@ export type AiChatTurnRunConfig = {
|
|
|
571
609
|
/** Stable public ID exposed as runtime context, not instructions. */
|
|
572
610
|
chatId?: string;
|
|
573
611
|
actor?: RequestActor;
|
|
612
|
+
/** Browser preferences retained for managed HTML self-tests. */
|
|
613
|
+
theme?: "light" | "dark";
|
|
614
|
+
timeZone?: string;
|
|
574
615
|
/** Request locale persisted with the turn so async execution keeps the caller preference. */
|
|
575
616
|
locale?: string;
|
|
576
617
|
modelPolicy?: AiModelPolicy;
|
|
@@ -707,7 +748,11 @@ export type AiConversationService = {
|
|
|
707
748
|
search?: string;
|
|
708
749
|
before?: string;
|
|
709
750
|
limit?: number;
|
|
710
|
-
|
|
751
|
+
/** Only these kinds; all kinds when omitted. */
|
|
752
|
+
kinds?: readonly AiConversationSourceKind[];
|
|
753
|
+
/** Only resources a tool call read; leaves out resources indexed as Project context without a call. */
|
|
754
|
+
observed?: boolean;
|
|
755
|
+
}): Promise<{ sources: AiConversationSource[]; nextCursor?: string; total: number }>;
|
|
711
756
|
getCapabilityInvocationOrigin(input: { idempotencyKey: string; toolName: string }): Promise<{
|
|
712
757
|
conversationId: string;
|
|
713
758
|
conversationShortId: string;
|
|
@@ -911,7 +956,10 @@ export type AiConversationService = {
|
|
|
911
956
|
conversationId: string;
|
|
912
957
|
turnId: string;
|
|
913
958
|
status: "completed" | "failed" | "aborted";
|
|
959
|
+
/** The reason a person reads, such as in `runError`; never raw provider text. */
|
|
914
960
|
error?: string | null;
|
|
961
|
+
/** Recorded on the turn's last message so its history shows why it failed. */
|
|
962
|
+
turnError?: AiTurnError | null;
|
|
915
963
|
/** When set, only the lease owner may finalize; otherwise only ownerless turns are finalized. */
|
|
916
964
|
leaseOwner?: string;
|
|
917
965
|
}): Promise<AiTurnCompletionResult>;
|
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import { Hono, type MiddlewareHandler } from "hono";
|
|
2
|
+
import { describeRoute } from "hono-openapi";
|
|
2
3
|
import { z } from "zod";
|
|
3
4
|
import { AiBackgroundCostError, backgroundCostState, releaseBackgroundCostStop } from "../ai/inference-calls";
|
|
4
5
|
import { setAiModelPricing } from "../ai/model-pricing";
|
|
6
|
+
import { AiModelRequestSettingsInvalid, getAiModelRequestSettings, setAiModelRequestSettings } from "../ai/model-request-settings";
|
|
5
7
|
import { quotaAdminConfig, quotaReport } from "../ai/quota-report";
|
|
6
8
|
import { AiQuotaError, aiQuotas } from "../ai/quotas";
|
|
7
9
|
import { readAiSettingsState } from "../ai/settings";
|
|
8
|
-
import { type AuthContext, auth, v } from "../server";
|
|
10
|
+
import { type AuthContext, auth, jsonResponse, requiresAdmin, v } from "../server";
|
|
9
11
|
import { AiModelPricingSchema, hasBillableAiPricing } from "../shared/ai-costs";
|
|
12
|
+
import { AiModelRequestSettingsSchema, AiModelRequestSettingsUpdateSchema } from "../shared/ai-model-request-settings";
|
|
10
13
|
import {
|
|
11
14
|
AiQuotaConfigSchema,
|
|
12
15
|
AiQuotaIdentitySchema,
|
|
@@ -21,6 +24,7 @@ export const createAdminAiQuotaRoutes = (authenticate: MiddlewareHandler<AuthCon
|
|
|
21
24
|
new Hono<AuthContext>()
|
|
22
25
|
.use("*", authenticate)
|
|
23
26
|
.onError((error, c) => {
|
|
27
|
+
if (error instanceof AiModelRequestSettingsInvalid) return c.json({ message: error.message }, 400);
|
|
24
28
|
if (error instanceof AiQuotaError || error instanceof AiBackgroundCostError)
|
|
25
29
|
return c.json({ error: error.code, message: error.message }, 409);
|
|
26
30
|
throw error;
|
|
@@ -46,6 +50,37 @@ export const createAdminAiQuotaRoutes = (authenticate: MiddlewareHandler<AuthCon
|
|
|
46
50
|
return c.json(await setAiModelPricing(c.req.param("id")!, data.pricing, data.expected, c.get("user")!.id));
|
|
47
51
|
},
|
|
48
52
|
)
|
|
53
|
+
.get(
|
|
54
|
+
"/models/:id/settings",
|
|
55
|
+
describeRoute({
|
|
56
|
+
tags: ["Administration"],
|
|
57
|
+
summary: "Read masked model request settings",
|
|
58
|
+
...requiresAdmin,
|
|
59
|
+
responses: { 200: jsonResponse(AiModelRequestSettingsSchema, "Model request settings (header names only)") },
|
|
60
|
+
}),
|
|
61
|
+
async (c) => {
|
|
62
|
+
c.header("Cache-Control", "no-store");
|
|
63
|
+
return c.json(await getAiModelRequestSettings(c.req.param("id")!));
|
|
64
|
+
},
|
|
65
|
+
)
|
|
66
|
+
.put(
|
|
67
|
+
"/models/:id/settings",
|
|
68
|
+
describeRoute({
|
|
69
|
+
tags: ["Administration"],
|
|
70
|
+
summary: "Patch model request settings with a revision guard",
|
|
71
|
+
...requiresAdmin,
|
|
72
|
+
responses: {
|
|
73
|
+
200: jsonResponse(AiModelRequestSettingsSchema, "Updated model request settings (header names only)"),
|
|
74
|
+
400: jsonResponse(z.object({ message: z.string() }), "Invalid model request settings"),
|
|
75
|
+
409: jsonResponse(z.object({ message: z.string(), error: z.string() }), "Model request settings changed"),
|
|
76
|
+
},
|
|
77
|
+
}),
|
|
78
|
+
v("json", AiModelRequestSettingsUpdateSchema),
|
|
79
|
+
async (c) => {
|
|
80
|
+
c.header("Cache-Control", "no-store");
|
|
81
|
+
return c.json(await setAiModelRequestSettings(c.req.param("id")!, c.req.valid("json"), c.get("user")!.id));
|
|
82
|
+
},
|
|
83
|
+
)
|
|
49
84
|
.get("/background", async (c) => c.json(await backgroundCostState()))
|
|
50
85
|
.post("/background/release", async (c) => c.json(await releaseBackgroundCostStop(c.get("user")!.id)))
|
|
51
86
|
.get("/report", v("query", AiQuotaReportQuerySchema), async (c) => c.json(await quotaReport(c.req.valid("query"))))
|
|
@@ -19,6 +19,7 @@ import {
|
|
|
19
19
|
aiModelAccess,
|
|
20
20
|
splitAiModelAccess,
|
|
21
21
|
} from "../ai/model-access";
|
|
22
|
+
import { listAiRequestHeaderNames, planAiProfileRequestHeaders, storeAiRequestHeaderPlan } from "../ai/request-headers";
|
|
22
23
|
import { parseAiModelProfiles, planAiProfileCredentials, validateAiSettingsConfiguration } from "../ai/settings";
|
|
23
24
|
import { type AiSettingsIssueWithMessage, aiSettingsNotSavedMessage, describeAiSettingsIssues } from "../ai/settings-messages";
|
|
24
25
|
import { type AuthContext, auth, getLocale, jsonResponse, requiresAdmin, v } from "../server";
|
|
@@ -27,7 +28,6 @@ import { readAccountCategoryPolicy } from "../services/account-category-policy";
|
|
|
27
28
|
import { audit } from "../services/audit";
|
|
28
29
|
import { validateFreeIpaCaCert } from "../services/freeipa-config";
|
|
29
30
|
import { testFreeIpaConnection } from "../services/ipa/connection";
|
|
30
|
-
import { sendEmail } from "../services/notifications/email";
|
|
31
31
|
import { GotenbergRenderError, testGotenberg } from "../services/pdf";
|
|
32
32
|
import * as settings from "../services/settings";
|
|
33
33
|
import { SETTINGS_MAP, validateSettingValue } from "../services/settings/defaults";
|
|
@@ -43,10 +43,6 @@ const BulkSaveSchema = z.union([
|
|
|
43
43
|
.strict(),
|
|
44
44
|
z.record(z.string(), z.unknown()).transform((updates) => ({ updates, resets: [] as string[] })),
|
|
45
45
|
]);
|
|
46
|
-
const TestEmailSchema = z.object({
|
|
47
|
-
recipient: z.email(),
|
|
48
|
-
});
|
|
49
|
-
|
|
50
46
|
const LEGAL_DOCUMENTS = [
|
|
51
47
|
{ kind: "terms", path: "/legal/terms" },
|
|
52
48
|
{ kind: "privacy", path: "/legal/privacy" },
|
|
@@ -101,6 +97,7 @@ type AiSettingsMutationPlan = {
|
|
|
101
97
|
/** Structured validation issues with stable codes; `errors` carries the same messages keyed by setting. */
|
|
102
98
|
issues?: AiSettingsIssueWithMessage[];
|
|
103
99
|
keepCredentialProfileIds?: string[];
|
|
100
|
+
headerPlan?: ReturnType<typeof planAiProfileRequestHeaders>;
|
|
104
101
|
modelProfileIds?: string[];
|
|
105
102
|
};
|
|
106
103
|
|
|
@@ -168,6 +165,17 @@ const prepareAiSettingsMutation = async (
|
|
|
168
165
|
keepCredentialProfileIds = credentialPlan.keepCredentialProfileIds;
|
|
169
166
|
}
|
|
170
167
|
|
|
168
|
+
const headerPlan =
|
|
169
|
+
profilesUpdated || profilesReset
|
|
170
|
+
? planAiProfileRequestHeaders({
|
|
171
|
+
currentProfiles: currentParsed.profiles,
|
|
172
|
+
nextProfiles: nextParsed.profiles,
|
|
173
|
+
existingNames: profilesUpdated ? await listAiRequestHeaderNames() : {},
|
|
174
|
+
submitted: aiSplit?.requestHeaders ?? [],
|
|
175
|
+
})
|
|
176
|
+
: undefined;
|
|
177
|
+
if (headerPlan?.error) return { errors: { [AI_PROFILES_KEY]: headerPlan.error } };
|
|
178
|
+
|
|
171
179
|
const issues = validateAiSettingsConfiguration({
|
|
172
180
|
enabled: nextEnabled,
|
|
173
181
|
defaultModelId: String(valueAfterMutation(AI_DEFAULT_MODEL_KEY, currentDefaultModelId ?? "", updates, resets)),
|
|
@@ -183,6 +191,7 @@ const prepareAiSettingsMutation = async (
|
|
|
183
191
|
return {
|
|
184
192
|
errors: described.errors,
|
|
185
193
|
issues: described.issues,
|
|
194
|
+
headerPlan,
|
|
186
195
|
keepCredentialProfileIds,
|
|
187
196
|
modelProfileIds: profilesUpdated || profilesReset ? nextParsed.profiles.map((profile) => profile.id) : undefined,
|
|
188
197
|
};
|
|
@@ -294,24 +303,6 @@ const app = new Hono<AuthContext>()
|
|
|
294
303
|
.delete("/legacy", auth.requireRole("admin"), async (c) => {
|
|
295
304
|
return c.json(await settingsDeleteLegacyKeys(await liveSettingKeys()));
|
|
296
305
|
})
|
|
297
|
-
.post("/test-email", auth.requireRole("admin"), v("json", TestEmailSchema), async (c) => {
|
|
298
|
-
const { recipient } = c.req.valid("json");
|
|
299
|
-
const sentAt = new Date().toISOString();
|
|
300
|
-
|
|
301
|
-
try {
|
|
302
|
-
await sendEmail(recipient, "Cloud test email", {
|
|
303
|
-
rawHtml: `
|
|
304
|
-
<p>This is a test email from Cloud.</p>
|
|
305
|
-
<p>If you received this message, SMTP delivery is configured correctly.</p>
|
|
306
|
-
<p style="margin-top:24px;color:#71717a;font-size:12px;">Sent at ${sentAt}</p>
|
|
307
|
-
`,
|
|
308
|
-
});
|
|
309
|
-
return c.json({ ok: true });
|
|
310
|
-
} catch (error) {
|
|
311
|
-
const message = error instanceof Error ? error.message : "Failed to send test email";
|
|
312
|
-
return c.json({ message }, 500);
|
|
313
|
-
}
|
|
314
|
-
})
|
|
315
306
|
.post("/run-ai-enrichment", auth.requireRole("admin"), async (c) => {
|
|
316
307
|
try {
|
|
317
308
|
// Small manual batch — the cron handles bulk; this is "kick it now".
|
|
@@ -463,6 +454,7 @@ const app = new Hono<AuthContext>()
|
|
|
463
454
|
} else if (aiPlan.keepCredentialProfileIds) {
|
|
464
455
|
await pruneAiCredentials(aiPlan.keepCredentialProfileIds, tx);
|
|
465
456
|
}
|
|
457
|
+
if (aiPlan.headerPlan) await storeAiRequestHeaderPlan(aiPlan.headerPlan, tx);
|
|
466
458
|
if (aiPlan.modelProfileIds) {
|
|
467
459
|
await aiModelAccess.syncProfiles(aiPlan.modelProfileIds, accessChanges, tx);
|
|
468
460
|
}
|
|
@@ -524,6 +516,7 @@ const app = new Hono<AuthContext>()
|
|
|
524
516
|
if (aiPlan.keepCredentialProfileIds) {
|
|
525
517
|
await pruneAiCredentials(aiPlan.keepCredentialProfileIds, tx);
|
|
526
518
|
}
|
|
519
|
+
if (aiPlan.headerPlan) await storeAiRequestHeaderPlan(aiPlan.headerPlan, tx);
|
|
527
520
|
if (aiPlan.modelProfileIds) {
|
|
528
521
|
await aiModelAccess.syncProfiles(aiPlan.modelProfileIds, [], tx);
|
|
529
522
|
}
|