@k2b/cloud 0.31.0 → 0.32.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 +6 -4
- package/src/_internal/define-app.ts +3 -1
- package/src/ai/app-skills.ts +1 -7
- package/src/ai/audio-tool.ts +2 -1
- package/src/ai/browser.ts +109 -0
- package/src/ai/capabilities.ts +6 -2
- package/src/ai/chart-block.ts +431 -81
- package/src/ai/chat/action-sentences.ts +13 -7
- package/src/ai/chat/blocks.tsx +11 -7
- package/src/ai/chat/builtin-tools.tsx +9 -7
- package/src/ai/chat/chart-block.tsx +218 -44
- package/src/ai/chat/explorer-block.tsx +276 -0
- package/src/ai/chat/file-tools.tsx +40 -1
- package/src/ai/chat/message-actions.tsx +6 -0
- package/src/ai/chat/message-utils.ts +7 -0
- package/src/ai/chat/messages.ts +16 -0
- package/src/ai/chat/presentation.tsx +13 -3
- package/src/ai/chat/reference-text.tsx +34 -0
- package/src/ai/chat/tool-groups.ts +1 -0
- package/src/ai/chat/tool-names.ts +14 -0
- package/src/ai/chat/turn-view.tsx +17 -7
- package/src/ai/chat/view-block.browser-harness.tsx +35 -0
- package/src/ai/chat/view-block.tsx +417 -0
- package/src/ai/chat/visual-tools.tsx +32 -53
- package/src/ai/client-tool-results.ts +9 -0
- package/src/ai/default-tool-contracts.ts +334 -91
- package/src/ai/default-tools.ts +87 -11
- package/src/ai/executor.ts +173 -31
- package/src/ai/file-error.ts +2 -0
- package/src/ai/file-tools.ts +27 -7
- package/src/ai/files-store.ts +60 -0
- package/src/ai/html-pdf-tool.ts +3 -2
- package/src/ai/http.ts +37 -12
- package/src/ai/index.ts +90 -0
- package/src/ai/local-tool-contracts.ts +244 -0
- package/src/ai/malformed-tool-call-repair.ts +144 -0
- package/src/ai/markdown-pdf-tool.ts +2 -1
- package/src/ai/migrate.ts +7 -0
- package/src/ai/openai-api-messages.ts +53 -0
- package/src/ai/openai-api-slots.ts +40 -0
- package/src/ai/openai-api.ts +383 -0
- package/src/ai/protocol.ts +6 -2
- package/src/ai/provider.ts +16 -4
- package/src/ai/request-context.ts +33 -0
- package/src/ai/routes.ts +96 -57
- package/src/ai/runtime.ts +48 -12
- package/src/ai/settings.ts +5 -0
- package/src/ai/skill-catalog.ts +20 -5
- package/src/ai/skill-format.ts +17 -7
- package/src/ai/skill-tool.ts +15 -5
- package/src/ai/skills-routes.ts +20 -6
- package/src/ai/skills.ts +26 -7
- package/src/ai/store.ts +25 -0
- package/src/ai/system-prompt.ts +69 -10
- package/src/ai/timeline.ts +3 -1
- package/src/ai/tools.ts +3 -0
- package/src/ai/types.ts +9 -0
- package/src/ai/view-block.ts +941 -0
- package/src/ai/vision-tool.ts +2 -1
- package/src/api/admin-identity.ts +6 -1
- package/src/api/app-approval-actions.ts +36 -0
- package/src/api/app-approval.ts +10 -2
- package/src/api/approvals-loader.ts +15 -0
- package/src/api/approvals.ts +120 -0
- package/src/api/index.ts +3 -0
- package/src/api/me.ts +12 -3
- package/src/approvals/Approvals.browser-harness.tsx +16 -0
- package/src/approvals/Approvals.tsx +621 -0
- package/src/approvals/ApprovalsNavStatus.tsx +20 -0
- package/src/approvals/messages.ts +95 -0
- package/src/approvals/ui.ts +8 -0
- package/src/browser/app-approval.ts +32 -0
- package/src/browser/choose-files.tsx +26 -13
- package/src/browser/commands.ts +13 -4
- package/src/browser/file-provider-error.ts +11 -0
- package/src/browser/file-providers.ts +3 -12
- package/src/browser/files.tsx +1 -2
- package/src/browser/live-connection.ts +2 -3
- package/src/browser/provider-list.ts +2 -1
- package/src/browser/save-files.tsx +13 -2
- package/src/cli/account.ts +21 -2
- package/src/cli/commands.ts +4 -2
- package/src/contracts/app-approval.ts +120 -0
- package/src/contracts/approval-paths.ts +4 -0
- package/src/contracts/approvals.ts +81 -0
- package/src/contracts/command-path.ts +17 -0
- package/src/contracts/commands.ts +5 -15
- package/src/contracts/shared.ts +5 -0
- package/src/events/live-protocol.ts +2 -1
- package/src/events/live-server-message.ts +40 -0
- package/src/events/testing.ts +88 -0
- package/src/server/index.ts +2 -0
- package/src/server/middleware/auth.ts +11 -1
- package/src/server/request-timeout.ts +22 -0
- package/src/server/services/access.ts +4 -3
- package/src/services/app-approval.ts +103 -15
- package/src/services/approvals/cursor.ts +15 -0
- package/src/services/approvals/device.ts +112 -0
- package/src/services/approvals/index.ts +27 -11
- package/src/services/approvals/list.ts +120 -0
- package/src/services/approvals/migrate.ts +9 -1
- package/src/services/identity/models-only-auth.ts +25 -0
- package/src/services/notifications/email-frame.ts +16 -3
- package/src/services/service-account-credentials.ts +22 -10
- package/src/services/settings/core-settings.ts +27 -0
- package/src/shared/ai-platform-prompt.ts +26 -1
- package/src/shared/email-html.ts +4 -0
- package/src/shared/index.ts +6 -1
- package/src/shared/markdown/extensions/code.ts +14 -0
- package/src/shared/markdown/index.ts +4 -4
- package/src/ssr/AppBadge.tsx +4 -4
- package/src/ssr/AppLaunchpadPanel.tsx +4 -4
- package/src/ssr/RailApps.island.tsx +2 -2
- package/src/ssr/app-badges.ts +50 -6
- package/src/ssr/page-trail.browser-harness.tsx +52 -3
- package/src/ssr/rail-navigation.ts +5 -3
- package/src/ssr/runtime.ts +6 -1
- package/src/styles/approvals.css +390 -0
- package/src/styles/global.css +5 -2
package/src/ai/protocol.ts
CHANGED
|
@@ -39,6 +39,8 @@ export type AiTurnBlock =
|
|
|
39
39
|
name: string;
|
|
40
40
|
args?: unknown;
|
|
41
41
|
status: AiToolBlockStatus;
|
|
42
|
+
/** ISO time the call reached its outcome. */
|
|
43
|
+
at?: string;
|
|
42
44
|
/** Localized transient host status, without tool payload data. */
|
|
43
45
|
progress?: string;
|
|
44
46
|
result?: unknown;
|
|
@@ -193,7 +195,7 @@ export const reconcileResolvedTurnActions = (
|
|
|
193
195
|
};
|
|
194
196
|
|
|
195
197
|
/** Decode complete provider names; hashes and ambiguous separators have no exact inverse. */
|
|
196
|
-
const canonicalToolName = (name: string, presentation?: AiToolPresentation): string => {
|
|
198
|
+
export const canonicalToolName = (name: string, presentation?: AiToolPresentation): string => {
|
|
197
199
|
if (name.length === 64 && /__[0-9a-f]{12}$/.test(name)) return name;
|
|
198
200
|
const decode = (value: string): string | undefined => {
|
|
199
201
|
const tokens = /[a-zA-Z0-9-]|__|_dot_|_u([0-9a-f]+)_/gy;
|
|
@@ -241,6 +243,7 @@ export const orderedWebsiteReceipts = (
|
|
|
241
243
|
export const buildBlocksFromMessages = (
|
|
242
244
|
messages: {
|
|
243
245
|
seq: number;
|
|
246
|
+
createdAt?: string;
|
|
244
247
|
message: Message;
|
|
245
248
|
meta?: {
|
|
246
249
|
steerId?: string;
|
|
@@ -253,7 +256,7 @@ export const buildBlocksFromMessages = (
|
|
|
253
256
|
const blocks: AiTurnBlock[] = [];
|
|
254
257
|
const toolIndex = new Map<string, number>();
|
|
255
258
|
|
|
256
|
-
for (const { seq, message, meta } of messages) {
|
|
259
|
+
for (const { seq, message, meta, createdAt } of messages) {
|
|
257
260
|
if (message.role === "assistant") {
|
|
258
261
|
message.content.forEach((block, index) => {
|
|
259
262
|
if (block.type === "text") {
|
|
@@ -298,6 +301,7 @@ export const buildBlocksFromMessages = (
|
|
|
298
301
|
status: outcome === "rejected" ? "rejected" : message.isError ? "failed" : "completed",
|
|
299
302
|
result: message.result,
|
|
300
303
|
isError: message.isError,
|
|
304
|
+
...(createdAt ? { at: createdAt } : {}),
|
|
301
305
|
...(outcome === "approved" ? { approved: true } : {}),
|
|
302
306
|
};
|
|
303
307
|
}
|
package/src/ai/provider.ts
CHANGED
|
@@ -1,9 +1,16 @@
|
|
|
1
1
|
import type { Provider, ReasoningEffort } from "@k2b/nessi/ai";
|
|
2
|
-
import { anthropic, gemini, mistral, ollama, openAICompatible, openai, openrouter } from "@k2b/nessi/ai";
|
|
2
|
+
import { anthropic, completeFromStream, gemini, mistral, ollama, openAICompatible, openai, openrouter } from "@k2b/nessi/ai";
|
|
3
3
|
import type { AiModelProfile } from "./types";
|
|
4
4
|
|
|
5
|
-
/** Bound
|
|
6
|
-
const PROVIDER_TIMEOUTS = { firstByteMs: 60_000, idleMs: 60_000 } as const;
|
|
5
|
+
/** Bound the first token and every gap between chunks to 60s, for chat and complete() calls. */
|
|
6
|
+
export const PROVIDER_TIMEOUTS = { firstByteMs: 60_000, idleMs: 60_000 } as const;
|
|
7
|
+
|
|
8
|
+
// Non-streaming calls receive headers only after the whole answer, so a first-byte timeout would cap total generation.
|
|
9
|
+
// Streaming makes the timeouts bound the first token and gaps between chunks, as for chat.
|
|
10
|
+
export const completeThroughStream = (provider: Provider): Provider => ({
|
|
11
|
+
...provider,
|
|
12
|
+
complete: (request) => completeFromStream(provider, request),
|
|
13
|
+
});
|
|
7
14
|
|
|
8
15
|
/**
|
|
9
16
|
* Reasoning for structured tasks and compaction: the requests every provider accepted before Nessi 0.17. A global
|
|
@@ -31,6 +38,10 @@ const commonOptions = (profile: AiModelProfile, apiKey?: string) => ({
|
|
|
31
38
|
|
|
32
39
|
export const createAiProvider = (profile: AiModelProfile, apiKey?: string, headers?: Record<string, string>): Provider => {
|
|
33
40
|
if (profile.capabilities.includes("transcription")) throw new Error("Transcription profiles cannot be used for chat generation.");
|
|
41
|
+
return completeThroughStream(createAdapter(profile, apiKey, headers));
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
const createAdapter = (profile: AiModelProfile, apiKey?: string, headers?: Record<string, string>): Provider => {
|
|
34
45
|
switch (profile.provider) {
|
|
35
46
|
case "openai":
|
|
36
47
|
return openai(profile.model, commonOptions(profile, apiKey));
|
|
@@ -39,7 +50,8 @@ export const createAiProvider = (profile: AiModelProfile, apiKey?: string, heade
|
|
|
39
50
|
case "anthropic":
|
|
40
51
|
return anthropic(profile.model, commonOptions(profile, apiKey));
|
|
41
52
|
case "mistral":
|
|
42
|
-
|
|
53
|
+
// Mistral requires nine alphanumeric characters; map stored and other providers' ids at the transport boundary.
|
|
54
|
+
return mistral(profile.model, { ...commonOptions(profile, apiKey), normalizeToolCallIds: "strict9" });
|
|
43
55
|
case "gemini":
|
|
44
56
|
return gemini(profile.model, commonOptions(profile, apiKey));
|
|
45
57
|
case "ollama":
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import type { Context } from "hono";
|
|
2
|
+
import { userFromActor } from "../server/actor";
|
|
3
|
+
import { type ApiErrorResponse, respond } from "../server/api";
|
|
4
|
+
import type { AuthContext, RequestActor } from "../server/middleware/auth";
|
|
5
|
+
import { err, fail } from "../server/services";
|
|
6
|
+
import type { AiToolApprovalContext } from "./approvals";
|
|
7
|
+
import { personalAiModelPolicy } from "./personal-agent";
|
|
8
|
+
import type { AiModelPolicy } from "./types";
|
|
9
|
+
|
|
10
|
+
/** Everything a resolved, authorized request needs to run against the shared runtime. */
|
|
11
|
+
export type AiChatRequestContext = {
|
|
12
|
+
actor: RequestActor;
|
|
13
|
+
ownerUserId: string;
|
|
14
|
+
modelPolicy: AiModelPolicy;
|
|
15
|
+
toolApprovalContext: AiToolApprovalContext;
|
|
16
|
+
/** A person in a signed-in browser session, not `cld`, an API key, or another delegated credential. */
|
|
17
|
+
signedInSession: boolean;
|
|
18
|
+
};
|
|
19
|
+
|
|
20
|
+
export const resolveAiChatRequestContext = async <T extends AuthContext>(
|
|
21
|
+
c: Context<T>,
|
|
22
|
+
): Promise<AiChatRequestContext | ApiErrorResponse> => {
|
|
23
|
+
const actor = c.get("actor");
|
|
24
|
+
const user = userFromActor(actor);
|
|
25
|
+
if (!user) return respond(c, fail(err.forbidden("AI conversations require a user-backed actor.")));
|
|
26
|
+
return {
|
|
27
|
+
actor,
|
|
28
|
+
ownerUserId: user.id,
|
|
29
|
+
modelPolicy: personalAiModelPolicy,
|
|
30
|
+
toolApprovalContext: { actorUserId: user.id },
|
|
31
|
+
signedInSession: actor.kind === "user" && !actor.delegation && c.get("credentialKind") === "session",
|
|
32
|
+
};
|
|
33
|
+
};
|
package/src/ai/routes.ts
CHANGED
|
@@ -2,9 +2,9 @@ import { type Context, Hono } from "hono";
|
|
|
2
2
|
import { bodyLimit } from "hono/body-limit";
|
|
3
3
|
import { z } from "zod";
|
|
4
4
|
import { listCapabilities } from "../_internal/registry";
|
|
5
|
+
import { APPROVAL_CLIENT_HEADER } from "../contracts/approvals";
|
|
5
6
|
import {
|
|
6
7
|
type AccessSubject,
|
|
7
|
-
type ApiErrorResponse,
|
|
8
8
|
type AuthContext,
|
|
9
9
|
auth,
|
|
10
10
|
err,
|
|
@@ -23,13 +23,13 @@ import { ImageMetadataError } from "../services/image-metadata";
|
|
|
23
23
|
import { logger } from "../services/logging";
|
|
24
24
|
import { coreSettings } from "../services/settings/api";
|
|
25
25
|
import { readThemeFromCookieHeader } from "../shared/theme";
|
|
26
|
-
import type { AiToolApprovalContext } from "./approvals";
|
|
27
26
|
import { assistantAiSettingsState, listAssistantAiModels, resolveAssistantAudioModel, selectAssistantAiModelId } from "./assistant-models";
|
|
28
27
|
import { AI_AUDIO_MAX_BYTES } from "./audio-format";
|
|
29
28
|
import { buildAiCapabilityCatalog } from "./capabilities";
|
|
30
29
|
import { aiChatMessages } from "./chat/messages";
|
|
31
30
|
import { getAiChatQuotas } from "./chat-quotas";
|
|
32
|
-
import {
|
|
31
|
+
import { AI_CLIENT_TOOL_ACTION_MAX_BYTES, aiClientToolOutputSchemas } from "./client-tool-results";
|
|
32
|
+
import { createCloudAiLocalBashTool, createCloudAiLocalTools, createConfiguredDefaultCloudAiTools } from "./default-tools";
|
|
33
33
|
import { enqueueAiDictation } from "./dictation-runtime";
|
|
34
34
|
import { AiDictationConflict, AiDictationListSchema, AiDictationStartSchema, aiDictations } from "./dictations";
|
|
35
35
|
import { aiProjectFilePathFromMount } from "./file-mount";
|
|
@@ -49,6 +49,7 @@ import {
|
|
|
49
49
|
toAiActionFailureResponse,
|
|
50
50
|
toAiErrorResponse,
|
|
51
51
|
} from "./http";
|
|
52
|
+
import { AI_LOCAL_TOOL_NAMES, AiLocalClientSchema } from "./local-tool-contracts";
|
|
52
53
|
import { aiMaintenanceJobs } from "./maintenance";
|
|
53
54
|
import { AI_MEMORY_CONTENT_MAX_CHARS, aiMemories } from "./memories";
|
|
54
55
|
import { aiMemoryLearningRuns } from "./memory-learning-runs";
|
|
@@ -61,10 +62,10 @@ import {
|
|
|
61
62
|
queuedMessageAccepted,
|
|
62
63
|
updateQueuedMessage,
|
|
63
64
|
} from "./message-queue";
|
|
64
|
-
import { personalAiModelPolicy } from "./personal-agent";
|
|
65
65
|
import { aiActorUser, aiPrefsUserId, aiUserPrefs } from "./prefs";
|
|
66
66
|
import { aiProjects } from "./projects";
|
|
67
67
|
import { projectPublicAiStoredMessages, publicAiStoredMessages } from "./public-projection";
|
|
68
|
+
import { type AiChatRequestContext, resolveAiChatRequestContext as resolveContext } from "./request-context";
|
|
68
69
|
import { aiResourceMarker } from "./resource-markers";
|
|
69
70
|
import { isConversationResourceCursor } from "./resource-refs";
|
|
70
71
|
import {
|
|
@@ -86,32 +87,7 @@ import { aiConversations } from "./store";
|
|
|
86
87
|
import { createAiConversationStreamResponse, loadAiStreamState } from "./stream";
|
|
87
88
|
import { composeAiSystemPrompt } from "./system-prompt";
|
|
88
89
|
import { aiToolPromptHints } from "./tools";
|
|
89
|
-
import type { AiConversation,
|
|
90
|
-
|
|
91
|
-
/** Everything a resolved, authorized request needs to run against the shared runtime. */
|
|
92
|
-
type AiChatRequestContext = {
|
|
93
|
-
actor: RequestActor;
|
|
94
|
-
ownerUserId: string;
|
|
95
|
-
modelPolicy: AiModelPolicy;
|
|
96
|
-
toolApprovalContext: AiToolApprovalContext;
|
|
97
|
-
/** A person in a signed-in browser session, not `cld`, an API key, or another delegated credential. */
|
|
98
|
-
signedInSession: boolean;
|
|
99
|
-
};
|
|
100
|
-
|
|
101
|
-
const actorUser = (actor: RequestActor) => (actor.kind === "user" ? actor.user : actor.delegatedUser);
|
|
102
|
-
|
|
103
|
-
const resolveContext = async (c: Context<AuthContext>): Promise<AiChatRequestContext | ApiErrorResponse> => {
|
|
104
|
-
const actor = c.get("actor");
|
|
105
|
-
const user = actorUser(actor);
|
|
106
|
-
if (!user) return respond(c, fail(err.forbidden("AI conversations require a user-backed actor.")));
|
|
107
|
-
return {
|
|
108
|
-
actor,
|
|
109
|
-
ownerUserId: user.id,
|
|
110
|
-
modelPolicy: personalAiModelPolicy,
|
|
111
|
-
toolApprovalContext: { actorUserId: user.id },
|
|
112
|
-
signedInSession: actor.kind === "user" && !actor.delegation && c.get("credentialKind") === "session",
|
|
113
|
-
};
|
|
114
|
-
};
|
|
90
|
+
import type { AiConversation, AiTurn } from "./types";
|
|
115
91
|
|
|
116
92
|
const retryInstruction = (mode: "retry" | "details" | "concise"): string | null => {
|
|
117
93
|
if (mode === "details") return "Answer the user's request again with more detail and specificity.";
|
|
@@ -330,7 +306,11 @@ export const aiRoutes = (() => {
|
|
|
330
306
|
.use(rateLimit())
|
|
331
307
|
.use("*", auth.requireRole("authenticated"))
|
|
332
308
|
.get("/status", async (c) => {
|
|
333
|
-
|
|
309
|
+
const [status, enabled] = await Promise.all([
|
|
310
|
+
assistantAiSettingsState(c.get("accessSubject")),
|
|
311
|
+
coreSettings.get<boolean>("ai.openai_api_enabled"),
|
|
312
|
+
]);
|
|
313
|
+
return respond(c, ok({ ...status, openaiApi: { enabled } }));
|
|
334
314
|
})
|
|
335
315
|
.get("/quotas", async (c) => {
|
|
336
316
|
c.header("Cache-Control", "no-store");
|
|
@@ -406,6 +386,21 @@ export const aiRoutes = (() => {
|
|
|
406
386
|
const user = aiActorUser(ctx.actor);
|
|
407
387
|
if (!user) return respond(c, fail(err.forbidden("AI preferences require a user context.")));
|
|
408
388
|
|
|
389
|
+
const clientKind = c.req.query("clientKind");
|
|
390
|
+
const parsedClient =
|
|
391
|
+
clientKind === undefined
|
|
392
|
+
? undefined
|
|
393
|
+
: AiLocalClientSchema.safeParse({
|
|
394
|
+
kind: clientKind,
|
|
395
|
+
name: c.req.query("clientName"),
|
|
396
|
+
cwd: c.req.query("cwd"),
|
|
397
|
+
os: c.req.query("os"),
|
|
398
|
+
shell: c.req.query("shell"),
|
|
399
|
+
localTools: c.req.query("localTools"),
|
|
400
|
+
});
|
|
401
|
+
if (parsedClient && !parsedClient.success)
|
|
402
|
+
return respond(c, fail(err.badInput(`Invalid local client: ${parsedClient.error.issues[0]?.message}`)));
|
|
403
|
+
const client = parsedClient?.success ? parsedClient.data : undefined;
|
|
409
404
|
const prefs = await aiUserPrefs.get(user.id);
|
|
410
405
|
const memoryEnabled = Boolean(prefs?.memoryEnabled);
|
|
411
406
|
const memory = memoryEnabled ? await aiMemories.selectHot(user.id, "") : null;
|
|
@@ -421,13 +416,18 @@ export const aiRoutes = (() => {
|
|
|
421
416
|
}
|
|
422
417
|
const toolsSupported = Boolean(previewProfile?.capabilities.includes("tools"));
|
|
423
418
|
const availableSkills = toolsSupported ? (await aiSkills.list(c.get("accessSubject"))).filter((skill) => skill.enabled) : [];
|
|
424
|
-
const skillCatalog = selectAiSkillCatalog(
|
|
419
|
+
const skillCatalog = selectAiSkillCatalog(
|
|
420
|
+
availableSkills.filter((skill) => !skill.manualOnly).map(({ name, description }) => ({ name, description })),
|
|
421
|
+
previewProfile?.contextWindow ?? 0,
|
|
422
|
+
"",
|
|
423
|
+
);
|
|
425
424
|
const tools = toolsSupported
|
|
426
425
|
? [
|
|
427
426
|
...(await createConfiguredDefaultCloudAiTools({
|
|
428
427
|
accessSubject: c.get("accessSubject"),
|
|
429
428
|
allowedDataBoundaries: ctx.modelPolicy.allowedDataBoundaries,
|
|
430
429
|
})),
|
|
430
|
+
...(client?.localTools === "ask" ? [createCloudAiLocalBashTool(), ...createCloudAiLocalTools()] : []),
|
|
431
431
|
...(memoryEnabled ? [createCloudAiMemoryTool()] : []),
|
|
432
432
|
...(availableSkills.length ? [createCloudAiLoadSkillTool(c.get("accessSubject"))] : []),
|
|
433
433
|
...(skillCatalog.omitted > 0 ? [createCloudAiSearchSkillsTool(c.get("accessSubject"))] : []),
|
|
@@ -438,6 +438,9 @@ export const aiRoutes = (() => {
|
|
|
438
438
|
const promptLocale = getLocale(c);
|
|
439
439
|
const prompt = composeAiSystemPrompt({
|
|
440
440
|
globalInstructions: state.globalInstructions,
|
|
441
|
+
localClient: client
|
|
442
|
+
? { client, cloudUrl: (await coreSettings.get<string>("app.url")) || "", instructions: state.localClientInstructions }
|
|
443
|
+
: undefined,
|
|
441
444
|
user,
|
|
442
445
|
memoryEnabled,
|
|
443
446
|
memoryToolEnabled,
|
|
@@ -450,7 +453,7 @@ export const aiRoutes = (() => {
|
|
|
450
453
|
memory: memory?.text,
|
|
451
454
|
timeZone,
|
|
452
455
|
locale: promptLocale,
|
|
453
|
-
skillCreatorAvailable: availableSkills.some((skill) => skill.name === "skill-creator"),
|
|
456
|
+
skillCreatorAvailable: availableSkills.some((skill) => skill.name === "skill-creator" && !skill.manualOnly),
|
|
454
457
|
});
|
|
455
458
|
return respond(c, ok({ prompt, renderedAt: new Date().toISOString() }));
|
|
456
459
|
})
|
|
@@ -907,6 +910,8 @@ export const aiRoutes = (() => {
|
|
|
907
910
|
modelPolicy: ctx.modelPolicy,
|
|
908
911
|
project: project ?? undefined,
|
|
909
912
|
clientToolIds: body.clientToolIds,
|
|
913
|
+
client: body.client,
|
|
914
|
+
localContext: body.localContext,
|
|
910
915
|
toolSource: { kind: "default" as const, appTools: true },
|
|
911
916
|
toolApprovalContext: ctx.toolApprovalContext,
|
|
912
917
|
expectedDraftRevision: body.draftRevision,
|
|
@@ -1022,7 +1027,20 @@ export const aiRoutes = (() => {
|
|
|
1022
1027
|
truncateFromSeq: target.seq,
|
|
1023
1028
|
fileSnapshot: originalRunConfig?.kind !== "compact" ? originalRunConfig?.files : undefined,
|
|
1024
1029
|
retrySourceTurnId: target.loopId ?? undefined,
|
|
1025
|
-
|
|
1030
|
+
// Local context and tools belong to the retrying client, not the original workspace.
|
|
1031
|
+
clientToolIds:
|
|
1032
|
+
body.clientToolIds ??
|
|
1033
|
+
(originalRunConfig?.kind !== "compact"
|
|
1034
|
+
? originalRunConfig?.clientToolIds?.filter((id) => id !== "local_bash" && !AI_LOCAL_TOOL_NAMES.some((name) => name === id))
|
|
1035
|
+
: undefined),
|
|
1036
|
+
client: body.client,
|
|
1037
|
+
localContext: body.client
|
|
1038
|
+
? {
|
|
1039
|
+
instructions: body.localContext?.instructions ?? [],
|
|
1040
|
+
skills: body.localContext?.skills ?? [],
|
|
1041
|
+
selectedSkills: originalRunConfig?.kind !== "compact" ? (originalRunConfig?.localContext?.selectedSkills ?? []) : [],
|
|
1042
|
+
}
|
|
1043
|
+
: undefined,
|
|
1026
1044
|
});
|
|
1027
1045
|
rememberLastUsedModel(ctx.actor, result.turn.modelProfileId);
|
|
1028
1046
|
return respond(
|
|
@@ -1114,29 +1132,49 @@ export const aiRoutes = (() => {
|
|
|
1114
1132
|
await abortAiTurn({ conversationId: conversation.id, turnId: turn.id });
|
|
1115
1133
|
return respond(c, ok({ ok: true }));
|
|
1116
1134
|
})
|
|
1117
|
-
.post(
|
|
1118
|
-
|
|
1119
|
-
|
|
1120
|
-
|
|
1121
|
-
|
|
1122
|
-
|
|
1123
|
-
|
|
1124
|
-
|
|
1125
|
-
|
|
1126
|
-
|
|
1127
|
-
|
|
1128
|
-
callId
|
|
1129
|
-
action
|
|
1130
|
-
|
|
1131
|
-
|
|
1132
|
-
|
|
1133
|
-
|
|
1134
|
-
|
|
1135
|
-
|
|
1136
|
-
|
|
1137
|
-
|
|
1138
|
-
|
|
1139
|
-
|
|
1135
|
+
.post(
|
|
1136
|
+
"/conversations/:conversationId/turns/:turnId/actions/:callId",
|
|
1137
|
+
bodyLimit({ maxSize: AI_CLIENT_TOOL_ACTION_MAX_BYTES }),
|
|
1138
|
+
v("json", AiTurnActionSchema),
|
|
1139
|
+
async (c) => {
|
|
1140
|
+
const ctx = await resolveContext(c);
|
|
1141
|
+
if (ctx instanceof Response) return ctx;
|
|
1142
|
+
const conversation = await loadConversation(c, ctx);
|
|
1143
|
+
if (!conversation) return notFound(c);
|
|
1144
|
+
const turn = await loadTurn(conversation, c.req.param("turnId"));
|
|
1145
|
+
const callId = c.req.param("callId");
|
|
1146
|
+
if (!turn || !callId) return notFound(c);
|
|
1147
|
+
const action = c.req.valid("json");
|
|
1148
|
+
if (action.type === "tool_result") {
|
|
1149
|
+
const pending = await aiConversations.getPendingTurnAction({ conversationId: conversation.id, turnId: turn.id, callId });
|
|
1150
|
+
const schema = pending?.kind === "client_tool" ? aiClientToolOutputSchemas.get(pending.name) : undefined;
|
|
1151
|
+
if (schema) {
|
|
1152
|
+
const parsed = schema.safeParse(action.result);
|
|
1153
|
+
if (!parsed.success)
|
|
1154
|
+
return respond(c, fail(err.badInput(`Invalid ${pending?.name} result: ${parsed.error.issues[0]?.message}`)));
|
|
1155
|
+
action.result = parsed.data;
|
|
1156
|
+
}
|
|
1157
|
+
}
|
|
1158
|
+
const result = await submitAiTurnAction({
|
|
1159
|
+
conversationId: conversation.id,
|
|
1160
|
+
turnId: turn.id,
|
|
1161
|
+
callId,
|
|
1162
|
+
action,
|
|
1163
|
+
decidedBy: {
|
|
1164
|
+
userId: ctx.ownerUserId,
|
|
1165
|
+
via: approvalDecisionChannel({
|
|
1166
|
+
credentialKind: c.get("credentialKind"),
|
|
1167
|
+
actor: ctx.actor,
|
|
1168
|
+
client: c.req.header(APPROVAL_CLIENT_HEADER),
|
|
1169
|
+
}),
|
|
1170
|
+
},
|
|
1171
|
+
toolApprovalContext: ctx.toolApprovalContext,
|
|
1172
|
+
signedInSession: ctx.signedInSession,
|
|
1173
|
+
});
|
|
1174
|
+
if (!result.ok) return toAiActionFailureResponse(c, result);
|
|
1175
|
+
return respond(c, ok({ ok: true }));
|
|
1176
|
+
},
|
|
1177
|
+
)
|
|
1140
1178
|
.get("/conversations/:conversationId/pending-actions/:turnId", async (c) => {
|
|
1141
1179
|
const ctx = await resolveContext(c);
|
|
1142
1180
|
if (ctx instanceof Response) return ctx;
|
|
@@ -1353,6 +1391,7 @@ export const aiRoutes = (() => {
|
|
|
1353
1391
|
"Content-Type": stored.mediaType || "application/octet-stream",
|
|
1354
1392
|
"Content-Disposition": `attachment; filename="${filename}"`,
|
|
1355
1393
|
"Content-Length": String(stored.size),
|
|
1394
|
+
"X-Cloud-Ai-File-Origin": stored.origin,
|
|
1356
1395
|
"Cache-Control": "private, no-store",
|
|
1357
1396
|
});
|
|
1358
1397
|
})
|
package/src/ai/runtime.ts
CHANGED
|
@@ -11,8 +11,9 @@ import { coreSettings } from "../services/settings/api";
|
|
|
11
11
|
import { AI_WEBSITE_APPROVAL_TOOL, type AiToolApprovalContext, aiTurnAllowsRememberedApprovals, rememberAiToolApproval } from "./approvals";
|
|
12
12
|
import { aiChatAccessSubject, selectAssistantAiModelId } from "./assistant-models";
|
|
13
13
|
import { startAiDictationRuntime } from "./dictation-runtime";
|
|
14
|
-
import { AiTurnExecutor } from "./executor";
|
|
14
|
+
import { AiTurnExecutor, type ExecutorConfig } from "./executor";
|
|
15
15
|
import { canonicalizeAiConversationAttachments, snapshotAiConversationFiles } from "./file-context";
|
|
16
|
+
import type { AiLocalClient, AiLocalContext } from "./local-tool-contracts";
|
|
16
17
|
import { drainQueuedMessages } from "./message-queue";
|
|
17
18
|
import { AI_TURN_LEASE_MS } from "./protocol";
|
|
18
19
|
import { aiQuotas } from "./quotas";
|
|
@@ -105,6 +106,8 @@ export type SubmitAiChatTurnInput = {
|
|
|
105
106
|
systemPrompt?: string;
|
|
106
107
|
project?: AiChatTurnRunConfig["project"];
|
|
107
108
|
clientToolIds?: AiClientToolId[];
|
|
109
|
+
client?: AiLocalClient;
|
|
110
|
+
localContext?: AiLocalContext;
|
|
108
111
|
toolSource?: AiTurnToolSource;
|
|
109
112
|
canInspectAttachedImages?: boolean;
|
|
110
113
|
toolApprovalContext?: AiToolApprovalContext;
|
|
@@ -119,6 +122,7 @@ export type SubmitAiChatTurnInput = {
|
|
|
119
122
|
};
|
|
120
123
|
|
|
121
124
|
export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
|
|
125
|
+
if (input.localContext && !input.client) throw new Error("Local context requires a local client.");
|
|
122
126
|
if (input.clientToolIds?.length && input.toolSource?.kind !== "default") {
|
|
123
127
|
throw new Error("Optional client tools require the default tool source.");
|
|
124
128
|
}
|
|
@@ -170,6 +174,8 @@ export const prepareAiChatTurn = async (input: SubmitAiChatTurnInput) => {
|
|
|
170
174
|
files,
|
|
171
175
|
canInspectAttachedImages,
|
|
172
176
|
clientToolIds: input.clientToolIds,
|
|
177
|
+
client: input.client,
|
|
178
|
+
localContext: input.localContext,
|
|
173
179
|
toolSource: input.toolSource ?? { kind: "none" },
|
|
174
180
|
toolApprovalContext: input.toolApprovalContext,
|
|
175
181
|
};
|
|
@@ -324,11 +330,11 @@ export const submitAiTurnAction = async (input: {
|
|
|
324
330
|
turnId: string;
|
|
325
331
|
callId: string;
|
|
326
332
|
action: AiTurnActionInput;
|
|
327
|
-
decidedBy: { userId: string; via: ApprovalDecisionChannel };
|
|
333
|
+
decidedBy: { userId: string; via: ApprovalDecisionChannel; deviceId?: string };
|
|
328
334
|
toolApprovalContext?: AiToolApprovalContext;
|
|
329
335
|
/** Whether a person decides in a signed-in browser session; only such a decision may remember a website. */
|
|
330
336
|
signedInSession?: boolean;
|
|
331
|
-
}): Promise<{ ok: true } | { ok: false; status: 400 | 404 | 409; message: string }> => {
|
|
337
|
+
}): Promise<{ ok: true } | { ok: false; status: 400 | 403 | 404 | 409; message: string }> => {
|
|
332
338
|
const pending = await aiConversations.getPendingTurnAction(input);
|
|
333
339
|
if (!pending) return { ok: false, status: 404, message: "This request has expired — the assistant already moved on." };
|
|
334
340
|
if (pending.status === "resolved") {
|
|
@@ -365,9 +371,11 @@ export const submitAiTurnAction = async (input: {
|
|
|
365
371
|
if (pending.approvalId) {
|
|
366
372
|
const decision = await approvals.decide(pending.approvalId, { approved: input.action.approved }, input.decidedBy);
|
|
367
373
|
if (!decision.ok)
|
|
368
|
-
return decision.code === "
|
|
369
|
-
? { ok: false, status:
|
|
370
|
-
:
|
|
374
|
+
return decision.code === "REJECT_ONLY"
|
|
375
|
+
? { ok: false, status: 403, message: "Approve this request in Cloud. An editor can only reject it." }
|
|
376
|
+
: decision.code === "ALREADY_DECIDED"
|
|
377
|
+
? { ok: false, status: 409, message: "AI action was already resolved with a different response." }
|
|
378
|
+
: { ok: false, status: 404, message: "This request has expired — the assistant already moved on." };
|
|
371
379
|
}
|
|
372
380
|
if (remember && input.toolApprovalContext) {
|
|
373
381
|
const toolName = pending.rememberToolName ?? pending.name;
|
|
@@ -425,11 +433,14 @@ export const submitAiTurnAction = async (input: {
|
|
|
425
433
|
// Worker + sweep lifecycle
|
|
426
434
|
// ---------------------------------------------------------------------------
|
|
427
435
|
|
|
436
|
+
type ApprovalRequestedListener = NonNullable<ExecutorConfig["onApprovalRequested"]>;
|
|
437
|
+
|
|
428
438
|
const runClaimedTurn = async (
|
|
429
439
|
job: AiTurnJob,
|
|
430
440
|
signal: AbortSignal,
|
|
431
441
|
leaseOwner: string,
|
|
432
442
|
onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>,
|
|
443
|
+
onApprovalRequested?: ApprovalRequestedListener,
|
|
433
444
|
): Promise<void> => {
|
|
434
445
|
const timeoutMinutes = await coreSettings.get<number>("ai.turn_timeout_minutes");
|
|
435
446
|
const runBudgetMs = Math.max(0, Number(timeoutMinutes ?? 30)) * 60_000;
|
|
@@ -458,6 +469,7 @@ const runClaimedTurn = async (
|
|
|
458
469
|
heartbeatMs: AI_TURN_HEARTBEAT_MS,
|
|
459
470
|
enqueueContinuation: (input) => enqueueAiTurn(input).then(() => undefined),
|
|
460
471
|
onTurnFinalized,
|
|
472
|
+
onApprovalRequested,
|
|
461
473
|
});
|
|
462
474
|
await executor.run({ conversationId: job.conversationId, turnId: job.turnId, claim, signal });
|
|
463
475
|
};
|
|
@@ -466,6 +478,7 @@ const processMessage = async (
|
|
|
466
478
|
message: Pick<QueueMessage<AiTurnJob>, "messageId" | "data" | "heartbeat">,
|
|
467
479
|
signal: AbortSignal,
|
|
468
480
|
onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>,
|
|
481
|
+
onApprovalRequested?: ApprovalRequestedListener,
|
|
469
482
|
): Promise<void> => {
|
|
470
483
|
const leaseOwner = `${AI_WORKER_ID}:${message.messageId}:${crypto.randomUUID()}`;
|
|
471
484
|
let heartbeatFailed = false;
|
|
@@ -488,7 +501,7 @@ const processMessage = async (
|
|
|
488
501
|
}, AI_TURN_HEARTBEAT_MS);
|
|
489
502
|
if (typeof touch === "object" && "unref" in touch) touch.unref();
|
|
490
503
|
try {
|
|
491
|
-
await runClaimedTurn(message.data, signal, leaseOwner, onTurnFinalized);
|
|
504
|
+
await runClaimedTurn(message.data, signal, leaseOwner, onTurnFinalized, onApprovalRequested);
|
|
492
505
|
} catch (error) {
|
|
493
506
|
log.error("AI turn worker error", {
|
|
494
507
|
conversationId: message.data.conversationId,
|
|
@@ -550,6 +563,7 @@ export const sweepAiRuntime = async (onTurnFinalized?: (event: AiTurnFinalizedEv
|
|
|
550
563
|
|
|
551
564
|
type AiRuntimeState = {
|
|
552
565
|
listeners: Set<(event: AiTurnFinalizedEvent) => Promise<void>>;
|
|
566
|
+
approvalListeners: Set<ApprovalRequestedListener>;
|
|
553
567
|
stop: () => void;
|
|
554
568
|
};
|
|
555
569
|
|
|
@@ -557,19 +571,29 @@ let running: AiRuntimeState | null = null;
|
|
|
557
571
|
let refCount = 0;
|
|
558
572
|
|
|
559
573
|
export const startAiRuntime = (
|
|
560
|
-
input: {
|
|
574
|
+
input: {
|
|
575
|
+
concurrency?: number;
|
|
576
|
+
onTurnFinalized?: (event: AiTurnFinalizedEvent) => Promise<void>;
|
|
577
|
+
onApprovalRequested?: ApprovalRequestedListener;
|
|
578
|
+
} = {},
|
|
561
579
|
): (() => void) => {
|
|
562
580
|
const listener = input.onTurnFinalized ? (event: AiTurnFinalizedEvent) => input.onTurnFinalized!(event) : null;
|
|
581
|
+
const approvalListener: ApprovalRequestedListener | null = input.onApprovalRequested
|
|
582
|
+
? (event) => input.onApprovalRequested!(event)
|
|
583
|
+
: null;
|
|
563
584
|
if (running) {
|
|
564
585
|
refCount += 1;
|
|
565
586
|
if (listener) running.listeners.add(listener);
|
|
566
|
-
|
|
587
|
+
if (approvalListener) running.approvalListeners.add(approvalListener);
|
|
588
|
+
return releaseAiRuntime(listener, approvalListener);
|
|
567
589
|
}
|
|
568
590
|
|
|
569
591
|
const concurrency = Math.min(Math.max(Math.floor(input.concurrency ?? AI_TURN_WORKER_CONCURRENCY), 1), 64);
|
|
570
592
|
const controller = new AbortController();
|
|
571
593
|
const listeners = new Set<(event: AiTurnFinalizedEvent) => Promise<void>>();
|
|
572
594
|
if (listener) listeners.add(listener);
|
|
595
|
+
const approvalListeners = new Set<ApprovalRequestedListener>();
|
|
596
|
+
if (approvalListener) approvalListeners.add(approvalListener);
|
|
573
597
|
refCount = 1;
|
|
574
598
|
const dispatchTurnFinalized = async (event: AiTurnFinalizedEvent): Promise<void> => {
|
|
575
599
|
const results = await Promise.allSettled([...listeners].map((notify) => notify(event)));
|
|
@@ -577,12 +601,18 @@ export const startAiRuntime = (
|
|
|
577
601
|
if (errors.length > 0) throw new AggregateError(errors, `${errors.length} AI turn finalized listener(s) failed`);
|
|
578
602
|
};
|
|
579
603
|
|
|
604
|
+
const dispatchApprovalRequested: ApprovalRequestedListener = async (event) => {
|
|
605
|
+
const results = await Promise.allSettled([...approvalListeners].map((notify) => notify(event)));
|
|
606
|
+
const errors = results.filter((result): result is PromiseRejectedResult => result.status === "rejected").map((result) => result.reason);
|
|
607
|
+
if (errors.length) throw new AggregateError(errors, `${errors.length} AI approval requested listener(s) failed`);
|
|
608
|
+
};
|
|
609
|
+
|
|
580
610
|
void superviseRuntimeTask({
|
|
581
611
|
name: "AI turn worker",
|
|
582
612
|
signal: controller.signal,
|
|
583
613
|
run: async (signal) => {
|
|
584
614
|
const worker = await aiTurnQueue().process({ concurrency, signal }, (message) =>
|
|
585
|
-
processMessage(message, message.signal, dispatchTurnFinalized),
|
|
615
|
+
processMessage(message, message.signal, dispatchTurnFinalized, dispatchApprovalRequested),
|
|
586
616
|
);
|
|
587
617
|
try {
|
|
588
618
|
await new Promise<void>((resolve) => {
|
|
@@ -612,22 +642,27 @@ export const startAiRuntime = (
|
|
|
612
642
|
const stopDictations = startAiDictationRuntime();
|
|
613
643
|
const state: AiRuntimeState = {
|
|
614
644
|
listeners,
|
|
645
|
+
approvalListeners,
|
|
615
646
|
stop: () => {
|
|
616
647
|
if (running !== state) return;
|
|
617
648
|
controller.abort();
|
|
618
649
|
stopDictations();
|
|
619
650
|
clearInterval(sweepTimer);
|
|
620
651
|
listeners.clear();
|
|
652
|
+
approvalListeners.clear();
|
|
621
653
|
running = null;
|
|
622
654
|
refCount = 0;
|
|
623
655
|
},
|
|
624
656
|
};
|
|
625
657
|
running = state;
|
|
626
658
|
log.info("AI runtime started", { workerId: AI_WORKER_ID, concurrency });
|
|
627
|
-
return releaseAiRuntime(listener);
|
|
659
|
+
return releaseAiRuntime(listener, approvalListener);
|
|
628
660
|
};
|
|
629
661
|
|
|
630
|
-
const releaseAiRuntime = (
|
|
662
|
+
const releaseAiRuntime = (
|
|
663
|
+
listener: ((event: AiTurnFinalizedEvent) => Promise<void>) | null,
|
|
664
|
+
approvalListener: ApprovalRequestedListener | null,
|
|
665
|
+
): (() => void) => {
|
|
631
666
|
let released = false;
|
|
632
667
|
return () => {
|
|
633
668
|
if (released) return;
|
|
@@ -635,6 +670,7 @@ const releaseAiRuntime = (listener: ((event: AiTurnFinalizedEvent) => Promise<vo
|
|
|
635
670
|
const state = running;
|
|
636
671
|
if (!state) return;
|
|
637
672
|
if (listener) state.listeners.delete(listener);
|
|
673
|
+
if (approvalListener) state.approvalListeners.delete(approvalListener);
|
|
638
674
|
refCount = Math.max(0, refCount - 1);
|
|
639
675
|
if (refCount === 0) state.stop();
|
|
640
676
|
};
|
package/src/ai/settings.ts
CHANGED
|
@@ -315,6 +315,7 @@ export const resolveAiSettingsStateFromRaw = async (input: {
|
|
|
315
315
|
visionModelId?: string;
|
|
316
316
|
profilesJson: string;
|
|
317
317
|
globalInstructions?: string;
|
|
318
|
+
localClientInstructions?: string;
|
|
318
319
|
compactionInstructions?: string;
|
|
319
320
|
maxToolResultChars?: unknown;
|
|
320
321
|
firecrawlApiKey?: string;
|
|
@@ -327,6 +328,7 @@ export const resolveAiSettingsStateFromRaw = async (input: {
|
|
|
327
328
|
defaultModelId: input.defaultModelId ?? "",
|
|
328
329
|
visionModelId: input.visionModelId ?? "",
|
|
329
330
|
globalInstructions: input.globalInstructions ?? "",
|
|
331
|
+
localClientInstructions: input.localClientInstructions ?? "",
|
|
330
332
|
compactionInstructions: input.compactionInstructions ?? "",
|
|
331
333
|
maxToolResultChars: normalizeMaxToolResultChars(input.maxToolResultChars),
|
|
332
334
|
firecrawlConfigured: Boolean(input.firecrawlApiKey?.trim()),
|
|
@@ -417,6 +419,7 @@ const readAiSettingsSnapshot = async (): Promise<{ state: AiSettingsState; crede
|
|
|
417
419
|
visionModelId,
|
|
418
420
|
profilesJson,
|
|
419
421
|
globalInstructions,
|
|
422
|
+
localClientInstructions,
|
|
420
423
|
compactionInstructions,
|
|
421
424
|
maxToolResultChars,
|
|
422
425
|
firecrawlApiKey,
|
|
@@ -426,6 +429,7 @@ const readAiSettingsSnapshot = async (): Promise<{ state: AiSettingsState; crede
|
|
|
426
429
|
coreSettings.get<string>("ai.vision_model_id"),
|
|
427
430
|
coreSettings.get<string>("ai.model_profiles_json"),
|
|
428
431
|
coreSettings.get<string>("ai.global_instructions"),
|
|
432
|
+
coreSettings.get<string>("ai.local_client_instructions"),
|
|
429
433
|
coreSettings.get<string>("ai.compaction_instructions"),
|
|
430
434
|
coreSettings.get<number>("ai.max_tool_result_chars"),
|
|
431
435
|
coreSettings.get<string>(AI_FIRECRAWL_API_KEY_SETTING_KEY),
|
|
@@ -446,6 +450,7 @@ const readAiSettingsSnapshot = async (): Promise<{ state: AiSettingsState; crede
|
|
|
446
450
|
visionModelId: visionModelId ?? "",
|
|
447
451
|
profilesJson: profilesJson ?? "[]",
|
|
448
452
|
globalInstructions: globalInstructions ?? "",
|
|
453
|
+
localClientInstructions: localClientInstructions ?? "",
|
|
449
454
|
compactionInstructions: compactionInstructions ?? "",
|
|
450
455
|
maxToolResultChars,
|
|
451
456
|
firecrawlApiKey: firecrawlApiKey ?? "",
|