@k2b/cloud 0.24.0 → 0.26.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 -2
- package/src/_internal/capabilities.ts +12 -0
- package/src/_internal/registry.ts +1 -0
- package/src/access/GroupCoverage.tsx +175 -0
- package/src/access/PermissionEditor.tsx +134 -93
- package/src/access/managers.ts +32 -0
- package/src/access/messages.ts +36 -0
- package/src/ai/approval-routes.ts +5 -5
- package/src/ai/capabilities.ts +34 -12
- package/src/ai/chat/blocks.tsx +86 -179
- package/src/ai/chat/builtin-tools.tsx +57 -30
- package/src/ai/chat/live-turn.browser-harness.tsx +37 -0
- package/src/ai/chat/message-actions.tsx +6 -2
- package/src/ai/chat/message-utils.ts +17 -14
- package/src/ai/chat/messages.ts +324 -2
- package/src/ai/chat/presentation.tsx +257 -103
- package/src/ai/chat/tool-groups.ts +55 -35
- package/src/ai/chat/turn-layout.ts +141 -0
- package/src/ai/chat/turn-view.tsx +609 -0
- package/src/ai/client/projection.ts +37 -6
- package/src/ai/executor.ts +38 -5
- package/src/ai/memory-learning.ts +7 -12
- package/src/ai/memory-workflow-evidence.ts +10 -2
- package/src/ai/migrate.ts +45 -2
- package/src/ai/model-access.ts +4 -2
- package/src/ai/prefs.ts +19 -5
- package/src/ai/projects.ts +5 -2
- package/src/ai/protocol.ts +20 -4
- package/src/ai/provider-fetch.ts +67 -15
- package/src/ai/provider-retry.ts +105 -0
- package/src/ai/quota-provider.ts +14 -5
- package/src/ai/skills.ts +5 -2
- package/src/ai/store.ts +108 -3
- package/src/ai/stream.ts +2 -0
- package/src/ai/timeline.ts +9 -11
- package/src/ai/turn-timing.ts +31 -3
- package/src/ai/types.ts +12 -2
- package/src/browser/FileChooser.tsx +600 -0
- package/src/browser/choose-files.browser-harness.tsx +32 -0
- package/src/browser/choose-files.tsx +70 -0
- package/src/browser/file-chooser-messages.ts +86 -0
- package/src/browser/file-providers.ts +217 -0
- package/src/browser/files.tsx +23 -0
- package/src/cli/admin/account-administration.ts +2 -2
- package/src/contracts/registry.ts +2 -0
- package/src/server/index.ts +1 -0
- package/src/server/services/access.ts +44 -11
- package/src/server/services/index.ts +1 -0
- package/src/shared/app-presentation.ts +10 -2
- package/src/ssr/platform-messages.ts +2 -2
- package/src/styles/effects.css +69 -0
- package/src/styles/file-chooser.css +169 -0
- package/src/styles/global.css +1 -0
package/src/ai/provider-fetch.ts
CHANGED
|
@@ -1,27 +1,63 @@
|
|
|
1
1
|
import { AsyncLocalStorage } from "node:async_hooks";
|
|
2
2
|
|
|
3
3
|
/**
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
4
|
+
* Observes the provider request of the inference call that is currently
|
|
5
|
+
* running, without touching the wire: when its headers and first body byte
|
|
6
|
+
* arrived, whether the provider certainly did not process it, and how long a
|
|
7
|
+
* rejecting provider asked the caller to wait. nessi adapters call the global
|
|
8
|
+
* `fetch`; the wrapper is installed once, on first use, and only observes
|
|
9
|
+
* requests made inside `runWithProviderFetchMarks`. Scopes nest, so an outer
|
|
10
|
+
* retry policy and the inner per-call accounting each see the same request.
|
|
8
11
|
*
|
|
9
12
|
* Nothing here logs: frames may carry reasoning text and headers carry keys.
|
|
10
13
|
*/
|
|
11
|
-
export type ProviderFetchMarks = {
|
|
14
|
+
export type ProviderFetchMarks = {
|
|
15
|
+
headersAt?: number;
|
|
16
|
+
firstByteAt?: number;
|
|
17
|
+
/** The request never reached the provider, or the provider answered with a status that says it did not process it. */
|
|
18
|
+
refused?: boolean;
|
|
19
|
+
retryAfterMs?: number;
|
|
20
|
+
};
|
|
12
21
|
|
|
13
|
-
const
|
|
22
|
+
const scopes = new AsyncLocalStorage<readonly ProviderFetchMarks[]>();
|
|
14
23
|
let installed = false;
|
|
15
24
|
|
|
16
25
|
export const runWithProviderFetchMarks = <T>(store: ProviderFetchMarks, run: () => Promise<T>): Promise<T> => {
|
|
17
26
|
install();
|
|
18
|
-
return
|
|
27
|
+
return scopes.run([...(scopes.getStore() ?? []), store], run);
|
|
28
|
+
};
|
|
29
|
+
|
|
30
|
+
/** `retry-after-ms` (OpenAI) wins over the standard `retry-after` seconds or HTTP date. */
|
|
31
|
+
export const retryAfterMs = (headers: Headers, now = Date.now()): number | undefined => {
|
|
32
|
+
const milliseconds = Number(headers.get("retry-after-ms")?.trim() || Number.NaN);
|
|
33
|
+
if (Number.isFinite(milliseconds) && milliseconds >= 0) return milliseconds;
|
|
34
|
+
const value = headers.get("retry-after")?.trim();
|
|
35
|
+
if (!value) return undefined;
|
|
36
|
+
const seconds = Number(value);
|
|
37
|
+
if (Number.isFinite(seconds)) return seconds >= 0 ? seconds * 1_000 : undefined;
|
|
38
|
+
const date = Date.parse(value);
|
|
39
|
+
return Number.isFinite(date) ? Math.max(0, date - now) : undefined;
|
|
19
40
|
};
|
|
20
41
|
|
|
21
|
-
|
|
42
|
+
/**
|
|
43
|
+
* A client error, 503, or Anthropic's overloaded 529 says the provider did not
|
|
44
|
+
* process the request. Other server errors, such as a gateway's 502 or 504, can
|
|
45
|
+
* follow processing upstream.
|
|
46
|
+
*/
|
|
47
|
+
const refusedStatus = (status: number) => (status >= 400 && status < 500) || status === 503 || status === 529;
|
|
48
|
+
|
|
49
|
+
/** Bun's codes for a request that never left: no connection, or no address for the host. */
|
|
50
|
+
const unsentCodes = new Set(["ConnectionRefused", "ENOTFOUND"]);
|
|
51
|
+
const unsent = (error: unknown) =>
|
|
52
|
+
typeof error === "object" && error !== null && "code" in error && typeof error.code === "string" && unsentCodes.has(error.code);
|
|
53
|
+
|
|
54
|
+
const firstByteObserver = (stores: readonly ProviderFetchMarks[]) =>
|
|
22
55
|
new TransformStream<Uint8Array, Uint8Array>({
|
|
23
56
|
transform(chunk, controller) {
|
|
24
|
-
if (chunk.byteLength > 0
|
|
57
|
+
if (chunk.byteLength > 0)
|
|
58
|
+
for (const store of stores) {
|
|
59
|
+
store.firstByteAt ??= Date.now();
|
|
60
|
+
}
|
|
25
61
|
controller.enqueue(chunk);
|
|
26
62
|
},
|
|
27
63
|
});
|
|
@@ -31,13 +67,29 @@ const install = (): void => {
|
|
|
31
67
|
installed = true;
|
|
32
68
|
const realFetch = globalThis.fetch;
|
|
33
69
|
const instrumented = async (input: RequestInfo | URL, init?: RequestInit): Promise<Response> => {
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
if (
|
|
37
|
-
|
|
38
|
-
|
|
70
|
+
// Only the first request of a scope is its provider request; a retry opens a new scope.
|
|
71
|
+
const stores = (scopes.getStore() ?? []).filter((store) => store.headersAt === undefined);
|
|
72
|
+
if (stores.length === 0) return realFetch(input, init);
|
|
73
|
+
let response: Response;
|
|
74
|
+
try {
|
|
75
|
+
response = await realFetch(input, init);
|
|
76
|
+
} catch (error) {
|
|
77
|
+
// A reset, an abort, or a timeout can follow delivery, so only an unsent request counts as refused.
|
|
78
|
+
if (unsent(error))
|
|
79
|
+
for (const store of stores) {
|
|
80
|
+
store.refused = true;
|
|
81
|
+
}
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
const headersAt = Date.now();
|
|
85
|
+
const wait = response.ok ? undefined : retryAfterMs(response.headers, headersAt);
|
|
86
|
+
for (const store of stores) {
|
|
87
|
+
store.headersAt = headersAt;
|
|
88
|
+
store.refused = refusedStatus(response.status);
|
|
89
|
+
if (wait !== undefined) store.retryAfterMs = wait;
|
|
90
|
+
}
|
|
39
91
|
if (!response.body) return response;
|
|
40
|
-
return new Response(response.body.pipeThrough(firstByteObserver(
|
|
92
|
+
return new Response(response.body.pipeThrough(firstByteObserver(stores)), {
|
|
41
93
|
status: response.status,
|
|
42
94
|
statusText: response.statusText,
|
|
43
95
|
headers: response.headers,
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
import { setTimeout as delay } from "node:timers/promises";
|
|
2
|
+
import type { NessiIssue, Provider, ProviderIssue, StreamEvent, TimeoutIssue } from "@k2b/nessi/ai";
|
|
3
|
+
import { type ProviderFetchMarks, runWithProviderFetchMarks } from "./provider-fetch";
|
|
4
|
+
|
|
5
|
+
/** Waits before the first and the second retry when the provider names none. nessi itself never retries. */
|
|
6
|
+
export const AI_PROVIDER_RETRY_DELAYS_MS: readonly number[] = [1_000, 4_000];
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* The longest Retry-After that is honored, the bound the OpenAI and Anthropic
|
|
10
|
+
* SDKs apply. A provider that asks for a longer wait is unavailable for this
|
|
11
|
+
* turn, so the call fails now with the provider's message.
|
|
12
|
+
*/
|
|
13
|
+
const AI_PROVIDER_MAX_RETRY_AFTER_MS = 60_000;
|
|
14
|
+
|
|
15
|
+
export type AiProviderRetry = {
|
|
16
|
+
/** 1 for the first retry. */
|
|
17
|
+
retry: number;
|
|
18
|
+
delayMs: number;
|
|
19
|
+
issue: ProviderIssue | TimeoutIssue;
|
|
20
|
+
};
|
|
21
|
+
|
|
22
|
+
const transientIssue = (issue: NessiIssue): issue is ProviderIssue | TimeoutIssue =>
|
|
23
|
+
(issue.kind === "provider_error" && issue.retryable && !issue.contextOverflow) ||
|
|
24
|
+
(issue.kind === "timeout" && issue.retryable && issue.scope !== "tool");
|
|
25
|
+
|
|
26
|
+
const isBlockEvent = (event: StreamEvent) => event.type === "block_start" || event.type === "block_delta" || event.type === "block_end";
|
|
27
|
+
|
|
28
|
+
/**
|
|
29
|
+
* Repeats a model call that failed transiently (429, 5xx, a lost connection, a
|
|
30
|
+
* provider timeout) before it produced any output. Each attempt passes through
|
|
31
|
+
* `provider` again, so quota admission and accounting see every request.
|
|
32
|
+
*
|
|
33
|
+
* Once a block event was emitted the attempt is final: streamed text cannot be
|
|
34
|
+
* taken back, so a failure mid-stream still ends the call. Context overflow is
|
|
35
|
+
* left to compaction. Waits follow the provider's Retry-After, otherwise
|
|
36
|
+
* `delaysMs`, end before `deadline`, and stop on the request's abort signal.
|
|
37
|
+
*/
|
|
38
|
+
export function retryTransientProviderErrors(
|
|
39
|
+
provider: Provider,
|
|
40
|
+
options: {
|
|
41
|
+
/** Epoch milliseconds by which a wait must end; null when the turn has no run time limit. */
|
|
42
|
+
deadline: number | null;
|
|
43
|
+
delaysMs?: readonly number[];
|
|
44
|
+
/** Announces a wait before it starts. */
|
|
45
|
+
onRetry?: (retry: AiProviderRetry) => Promise<void>;
|
|
46
|
+
},
|
|
47
|
+
): Provider {
|
|
48
|
+
const delays = options.delaysMs ?? AI_PROVIDER_RETRY_DELAYS_MS;
|
|
49
|
+
const waitFor = (retry: number, marks: ProviderFetchMarks, signal: AbortSignal | undefined): number | null => {
|
|
50
|
+
if (retry >= delays.length || signal?.aborted) return null;
|
|
51
|
+
const delayMs = marks.retryAfterMs ?? delays[retry]!;
|
|
52
|
+
if (delayMs > AI_PROVIDER_MAX_RETRY_AFTER_MS) return null;
|
|
53
|
+
if (options.deadline !== null && Date.now() + delayMs >= options.deadline) return null;
|
|
54
|
+
return delayMs;
|
|
55
|
+
};
|
|
56
|
+
return {
|
|
57
|
+
name: provider.name,
|
|
58
|
+
family: provider.family,
|
|
59
|
+
model: provider.model,
|
|
60
|
+
contextWindow: provider.contextWindow,
|
|
61
|
+
capabilities: provider.capabilities,
|
|
62
|
+
complete: (request) => provider.complete(request),
|
|
63
|
+
stream: async function* (request) {
|
|
64
|
+
for (let retry = 0; ; retry += 1) {
|
|
65
|
+
const marks: ProviderFetchMarks = {};
|
|
66
|
+
const events = provider.stream(request)[Symbol.asyncIterator]();
|
|
67
|
+
const next = () => runWithProviderFetchMarks(marks, () => events.next());
|
|
68
|
+
// Events before the first block (usage, issues) are held so that a retried attempt leaves no trace.
|
|
69
|
+
const held: StreamEvent[] = [];
|
|
70
|
+
let committed = false;
|
|
71
|
+
let pending: AiProviderRetry | null = null;
|
|
72
|
+
try {
|
|
73
|
+
for (let step = await next(); !step.done; step = await next()) {
|
|
74
|
+
const event = step.value;
|
|
75
|
+
if (!committed && event.type === "issue" && transientIssue(event.issue)) {
|
|
76
|
+
const delayMs = waitFor(retry, marks, request.signal);
|
|
77
|
+
if (delayMs !== null) {
|
|
78
|
+
pending = { retry: retry + 1, delayMs, issue: event.issue };
|
|
79
|
+
break;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
82
|
+
if (!committed && !isBlockEvent(event)) {
|
|
83
|
+
held.push(event);
|
|
84
|
+
continue;
|
|
85
|
+
}
|
|
86
|
+
if (!committed) {
|
|
87
|
+
committed = true;
|
|
88
|
+
yield* held.splice(0);
|
|
89
|
+
}
|
|
90
|
+
yield event;
|
|
91
|
+
}
|
|
92
|
+
} finally {
|
|
93
|
+
// Settles the failed attempt's accounting before the wait starts.
|
|
94
|
+
await events.return?.();
|
|
95
|
+
}
|
|
96
|
+
if (!pending) {
|
|
97
|
+
yield* held;
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
await options.onRetry?.(pending);
|
|
101
|
+
await delay(pending.delayMs, undefined, { signal: request.signal });
|
|
102
|
+
}
|
|
103
|
+
},
|
|
104
|
+
};
|
|
105
|
+
}
|
package/src/ai/quota-provider.ts
CHANGED
|
@@ -5,7 +5,7 @@ import type { AccessSubject } from "../server/services/access";
|
|
|
5
5
|
import { logger } from "../services/logging";
|
|
6
6
|
import { isAssistantChatTurn } from "./assistant-models";
|
|
7
7
|
import { AiBackgroundAdmissionError, type AiCallContext, type AiCallDetails, beginAiCall, finishAiCall } from "./inference-calls";
|
|
8
|
-
import { runWithProviderFetchMarks } from "./provider-fetch";
|
|
8
|
+
import { type ProviderFetchMarks, runWithProviderFetchMarks } from "./provider-fetch";
|
|
9
9
|
import type { AiModelProfile } from "./types";
|
|
10
10
|
|
|
11
11
|
const log = logger("ai:quotas");
|
|
@@ -93,7 +93,7 @@ export function inferenceProvider(
|
|
|
93
93
|
let status: "ok" | "failed" = "failed";
|
|
94
94
|
let error: string | undefined;
|
|
95
95
|
const requestStartedAt = Date.now();
|
|
96
|
-
const marks:
|
|
96
|
+
const marks: ProviderFetchMarks = {};
|
|
97
97
|
try {
|
|
98
98
|
const result = await runWithProviderFetchMarks(marks, () =>
|
|
99
99
|
provider.complete({ ...request, maxOutputTokens: call.maxOutputTokens }),
|
|
@@ -112,7 +112,9 @@ export function inferenceProvider(
|
|
|
112
112
|
throw thrown;
|
|
113
113
|
} finally {
|
|
114
114
|
call.stop();
|
|
115
|
-
|
|
115
|
+
// A request the provider refused or never received cost nothing; any other failure may have been processed.
|
|
116
|
+
if (!usage && status === "failed")
|
|
117
|
+
usage = marks.refused ? { input: 0, output: 0 } : { input: call.inputTokens, output: 0, estimated: true };
|
|
116
118
|
const cancelled = request.signal?.aborted === true;
|
|
117
119
|
await finish(call.id, usage, cancelled && status === "failed" ? "aborted" : status, {
|
|
118
120
|
error: cancelled ? null : error,
|
|
@@ -133,7 +135,7 @@ export function inferenceProvider(
|
|
|
133
135
|
const outputBlocks = new Map<string, number>();
|
|
134
136
|
let generated = false;
|
|
135
137
|
const requestStartedAt = Date.now();
|
|
136
|
-
const marks:
|
|
138
|
+
const marks: ProviderFetchMarks & { firstBlockAt?: number } = {};
|
|
137
139
|
// The wrapped adapter reads lazily, so the request only leaves once the first pull runs inside the marked scope.
|
|
138
140
|
const events = provider.stream({ ...request, maxOutputTokens: call.maxOutputTokens })[Symbol.asyncIterator]();
|
|
139
141
|
const next = () => runWithProviderFetchMarks(marks, () => events.next());
|
|
@@ -158,7 +160,14 @@ export function inferenceProvider(
|
|
|
158
160
|
outputBlocks.set(event.blockId, size);
|
|
159
161
|
}
|
|
160
162
|
if (event.type === "block_start" || event.type === "block_delta" || event.type === "block_end") generated = true;
|
|
161
|
-
|
|
163
|
+
// A provider that refused the request, or never received it, did not process it.
|
|
164
|
+
if (
|
|
165
|
+
event.type === "issue" &&
|
|
166
|
+
event.issue.kind === "provider_error" &&
|
|
167
|
+
(event.issue.contextOverflow || marks.refused) &&
|
|
168
|
+
!generated &&
|
|
169
|
+
!usage
|
|
170
|
+
)
|
|
162
171
|
usage = { input: 0, output: 0 };
|
|
163
172
|
if (event.type === "usage") {
|
|
164
173
|
if (event.finishReason === "aborted" || event.finishReason === "interrupted" || event.finishReason === "error") failed = true;
|
package/src/ai/skills.ts
CHANGED
|
@@ -58,6 +58,7 @@ export type AiSkillAccess = {
|
|
|
58
58
|
principal: Principal;
|
|
59
59
|
permission: AiSkillPermission;
|
|
60
60
|
displayName?: string;
|
|
61
|
+
avatarHash?: string | null;
|
|
61
62
|
/** Kind of a `service_account` principal; presentation only. */
|
|
62
63
|
serviceAccountKind?: ServiceAccountKind;
|
|
63
64
|
createdAt: string;
|
|
@@ -130,6 +131,7 @@ type SkillAccessRow = {
|
|
|
130
131
|
permission: AiSkillPermission;
|
|
131
132
|
created_at: Date | string;
|
|
132
133
|
display_name: string | null;
|
|
134
|
+
avatar_hash: string | null;
|
|
133
135
|
service_account_kind: ServiceAccountKind | null;
|
|
134
136
|
};
|
|
135
137
|
|
|
@@ -284,9 +286,9 @@ const listSkillAccess = async (skillId: string, db: SQL = sql): Promise<AiSkillA
|
|
|
284
286
|
const rows = await db<SkillAccessRow[]>`
|
|
285
287
|
SELECT skill_access.short_id, access.user_id, access.group_id, access.service_account_id, access.authenticated_only,
|
|
286
288
|
access.permission, access.created_at,
|
|
287
|
-
COALESCE(users.display_name, groups.name, service_accounts.name,
|
|
289
|
+
COALESCE(NULLIF(users.display_name, ''), users.uid, groups.name, service_accounts.name,
|
|
288
290
|
CASE WHEN access.authenticated_only THEN 'All authenticated users' ELSE 'Public' END) AS display_name,
|
|
289
|
-
service_accounts.kind AS service_account_kind
|
|
291
|
+
users.avatar_hash, service_accounts.kind AS service_account_kind
|
|
290
292
|
FROM ai.skill_access skill_access
|
|
291
293
|
JOIN auth.access access ON access.id = skill_access.access_id
|
|
292
294
|
LEFT JOIN auth.users users ON users.id = access.user_id
|
|
@@ -309,6 +311,7 @@ const listSkillAccess = async (skillId: string, db: SQL = sql): Promise<AiSkillA
|
|
|
309
311
|
: { type: "public" },
|
|
310
312
|
permission: row.permission,
|
|
311
313
|
displayName: row.display_name ?? undefined,
|
|
314
|
+
...(row.user_id ? { avatarHash: row.avatar_hash } : {}),
|
|
312
315
|
serviceAccountKind: row.service_account_kind ?? undefined,
|
|
313
316
|
createdAt: iso(row.created_at),
|
|
314
317
|
}));
|
package/src/ai/store.ts
CHANGED
|
@@ -4,9 +4,11 @@ import { type SQL, sql } from "bun";
|
|
|
4
4
|
import { type CapabilityActionReview, CapabilityActionReviewSchema } from "../contracts/capabilities";
|
|
5
5
|
import { logger } from "../services/logging";
|
|
6
6
|
import { toPgTextArray } from "../services/postgres";
|
|
7
|
+
import { AI_MEMORY_LEARNING_DEFAULT_ENABLED } from "./prefs";
|
|
7
8
|
import type { AiTurnBlock } from "./protocol";
|
|
8
9
|
import { withAiShortId, withAiShortIdForDb } from "./short-id";
|
|
9
10
|
import { parseAiTodoPlan } from "./todo-contracts";
|
|
11
|
+
import { activeTurnWaits } from "./turn-timing";
|
|
10
12
|
import type {
|
|
11
13
|
AiConversation,
|
|
12
14
|
AiConversationDraft,
|
|
@@ -776,6 +778,76 @@ const messageSearchText = (message: Message): string => {
|
|
|
776
778
|
return text.slice(0, SEARCH_TEXT_MAX_CHARS);
|
|
777
779
|
};
|
|
778
780
|
|
|
781
|
+
/**
|
|
782
|
+
* Record how a turn ended on its messages when its loop could not record it: a stop while an approval waits, a run
|
|
783
|
+
* time limit, or a turn the sweep finalizes. History reads the ending from the last assistant message, so such a turn
|
|
784
|
+
* never looks finished. A failure replaces the `aborted` that a loop cut off by its run time limit recorded, so the
|
|
785
|
+
* limit never looks like a user stop. A call the user approved that never returned keeps its approval in history.
|
|
786
|
+
*/
|
|
787
|
+
const recordTurnEnd = async (
|
|
788
|
+
db: typeof sql,
|
|
789
|
+
input: { conversationId: string; turnId: string; reason: "aborted" | "error" },
|
|
790
|
+
): Promise<void> => {
|
|
791
|
+
const replaces = input.reason === "error" ? "aborted" : null;
|
|
792
|
+
for (const table of ["ai.messages", "ai.task_messages"]) {
|
|
793
|
+
await db`
|
|
794
|
+
UPDATE ${db(table)}
|
|
795
|
+
SET loop_done_reason = ${input.reason}
|
|
796
|
+
WHERE id = (
|
|
797
|
+
SELECT id
|
|
798
|
+
FROM ${db(table)}
|
|
799
|
+
WHERE conversation_id = ${input.conversationId}
|
|
800
|
+
AND loop_id = ${input.turnId}::text
|
|
801
|
+
AND compacted_at IS NULL
|
|
802
|
+
AND kind = 'message'
|
|
803
|
+
AND role = 'assistant'
|
|
804
|
+
ORDER BY seq DESC
|
|
805
|
+
LIMIT 1
|
|
806
|
+
)
|
|
807
|
+
AND (loop_done_reason IS NULL OR loop_done_reason = ${replaces}::text)
|
|
808
|
+
`;
|
|
809
|
+
// An approved call without a result keeps the decision on the message that holds the call. A decision on a custom
|
|
810
|
+
// approval belongs to the call that asked for it.
|
|
811
|
+
await db`
|
|
812
|
+
UPDATE ${db(table)} target
|
|
813
|
+
SET meta = jsonb_set(
|
|
814
|
+
COALESCE(target.meta, '{}'::jsonb),
|
|
815
|
+
'{toolOutcomes}',
|
|
816
|
+
COALESCE(target.meta->'toolOutcomes', '{}'::jsonb) || outcomes.value
|
|
817
|
+
)
|
|
818
|
+
FROM (
|
|
819
|
+
SELECT message.id, jsonb_object_agg(approved.call_id, 'approved'::text) AS value
|
|
820
|
+
FROM (
|
|
821
|
+
SELECT DISTINCT
|
|
822
|
+
CASE
|
|
823
|
+
WHEN action.kind = 'custom_approval' THEN COALESCE(substring(action.call_id FROM '^(.*)-approval-[0-9]+$'), action.call_id)
|
|
824
|
+
ELSE action.call_id
|
|
825
|
+
END AS call_id
|
|
826
|
+
FROM ai.pending_actions action
|
|
827
|
+
WHERE action.turn_id = ${input.turnId}
|
|
828
|
+
AND action.resolved_event->>'type' = 'approval_response'
|
|
829
|
+
AND action.resolved_event->>'approved' = 'true'
|
|
830
|
+
) approved
|
|
831
|
+
JOIN ${db(table)} message
|
|
832
|
+
ON message.conversation_id = ${input.conversationId}
|
|
833
|
+
AND message.loop_id = ${input.turnId}::text
|
|
834
|
+
AND message.role = 'assistant'
|
|
835
|
+
AND message.message->'content' @> jsonb_build_array(jsonb_build_object('type', 'tool_call', 'id', approved.call_id))
|
|
836
|
+
WHERE NOT EXISTS (
|
|
837
|
+
SELECT 1
|
|
838
|
+
FROM ${db(table)} result
|
|
839
|
+
WHERE result.conversation_id = ${input.conversationId}
|
|
840
|
+
AND result.loop_id = ${input.turnId}::text
|
|
841
|
+
AND result.role = 'tool_result'
|
|
842
|
+
AND result.message->>'callId' = approved.call_id
|
|
843
|
+
)
|
|
844
|
+
GROUP BY message.id
|
|
845
|
+
) outcomes
|
|
846
|
+
WHERE target.id = outcomes.id
|
|
847
|
+
`;
|
|
848
|
+
}
|
|
849
|
+
};
|
|
850
|
+
|
|
779
851
|
/** Insert a message inside an open conversation-lock transaction and bump the conversation. */
|
|
780
852
|
const insertMessageLocked = async (
|
|
781
853
|
input: {
|
|
@@ -924,10 +996,14 @@ const toolMessageMeta = (
|
|
|
924
996
|
message: Message,
|
|
925
997
|
presentations: ReadonlyMap<string, AiToolPresentation> | undefined,
|
|
926
998
|
rejectedToolCallIds: ReadonlySet<string> | undefined,
|
|
999
|
+
approvedToolCallIds?: ReadonlySet<string>,
|
|
927
1000
|
): AiStoredMessage["meta"] => {
|
|
928
1001
|
if (message.role === "tool_result" && rejectedToolCallIds?.has(message.callId)) {
|
|
929
1002
|
return { toolOutcomes: { [message.callId]: "rejected" } };
|
|
930
1003
|
}
|
|
1004
|
+
if (message.role === "tool_result" && approvedToolCallIds?.has(message.callId)) {
|
|
1005
|
+
return { toolOutcomes: { [message.callId]: "approved" } };
|
|
1006
|
+
}
|
|
931
1007
|
if (message.role !== "assistant" || !presentations || presentations.size === 0) return null;
|
|
932
1008
|
const toolPresentations = Object.fromEntries(
|
|
933
1009
|
message.content.flatMap((block) => {
|
|
@@ -2617,10 +2693,14 @@ export const aiConversations: AiConversationService = {
|
|
|
2617
2693
|
LIMIT 1
|
|
2618
2694
|
`;
|
|
2619
2695
|
if (!rows[0]) return null;
|
|
2696
|
+
// The waits let a reconnecting client show work time without time spent waiting for the user.
|
|
2697
|
+
const waits = await activeTurnWaits(rows[0].id);
|
|
2620
2698
|
return {
|
|
2621
2699
|
turn: rowToTurn(rows[0]),
|
|
2622
2700
|
liveBlocks: rowToLiveBlocks(rows[0]),
|
|
2623
2701
|
liveSeq: Number(rows[0].live_seq ?? 0),
|
|
2702
|
+
actionWaitMs: Math.max(0, Math.round(waits.actionWaitMs)),
|
|
2703
|
+
waitingSince: waits.waitingSince,
|
|
2624
2704
|
};
|
|
2625
2705
|
},
|
|
2626
2706
|
|
|
@@ -2801,6 +2881,19 @@ export const aiConversations: AiConversationService = {
|
|
|
2801
2881
|
live_blocks = NULL
|
|
2802
2882
|
WHERE id = ${input.turnId}
|
|
2803
2883
|
`;
|
|
2884
|
+
if (input.status === "completed") {
|
|
2885
|
+
// Learning considers only turns that finish while it is on for the chat
|
|
2886
|
+
// owner; turning it on later does not reach back to this turn.
|
|
2887
|
+
await tx`
|
|
2888
|
+
UPDATE ai.turns turn
|
|
2889
|
+
SET memory_learned_at = turn.completed_at
|
|
2890
|
+
FROM ai.conversations conversation
|
|
2891
|
+
LEFT JOIN ai.user_prefs prefs ON prefs.user_id = conversation.created_by_user_id
|
|
2892
|
+
WHERE turn.id = ${input.turnId}
|
|
2893
|
+
AND conversation.id = turn.conversation_id
|
|
2894
|
+
AND NOT COALESCE(prefs.memory_learning_enabled, ${AI_MEMORY_LEARNING_DEFAULT_ENABLED})
|
|
2895
|
+
`;
|
|
2896
|
+
}
|
|
2804
2897
|
await tx`
|
|
2805
2898
|
UPDATE ai.pending_actions
|
|
2806
2899
|
SET status = 'aborted', resolved_at = COALESCE(resolved_at, now())
|
|
@@ -2808,6 +2901,11 @@ export const aiConversations: AiConversationService = {
|
|
|
2808
2901
|
AND status = 'pending'
|
|
2809
2902
|
`;
|
|
2810
2903
|
if (input.status !== "completed") {
|
|
2904
|
+
await recordTurnEnd(tx, {
|
|
2905
|
+
conversationId: input.conversationId,
|
|
2906
|
+
turnId: input.turnId,
|
|
2907
|
+
reason: input.status === "aborted" ? "aborted" : "error",
|
|
2908
|
+
});
|
|
2811
2909
|
await tx`
|
|
2812
2910
|
UPDATE ai.turn_steers
|
|
2813
2911
|
SET status = 'discarded', consumed_at = COALESCE(consumed_at, now())
|
|
@@ -2908,7 +3006,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2908
3006
|
}));
|
|
2909
3007
|
|
|
2910
3008
|
// 3) Finalize aborts: cancel-requested turns without a live lease, and expired waits.
|
|
2911
|
-
const abortedRows = await sql<{ id: string; conversation_id: string; attempt: number; live_seq: number | string }[]>`
|
|
3009
|
+
const abortedRows = await sql<{ id: string; conversation_id: string; attempt: number; live_seq: number | string; stopped: boolean }[]>`
|
|
2912
3010
|
UPDATE ai.turns
|
|
2913
3011
|
SET status = 'aborted',
|
|
2914
3012
|
completed_at = now(),
|
|
@@ -2925,7 +3023,7 @@ export const aiConversations: AiConversationService = {
|
|
|
2925
3023
|
)
|
|
2926
3024
|
LIMIT ${limit}
|
|
2927
3025
|
)
|
|
2928
|
-
RETURNING id, conversation_id, attempt, live_seq
|
|
3026
|
+
RETURNING id, conversation_id, attempt, live_seq, cancel_requested_at IS NOT NULL AS stopped
|
|
2929
3027
|
`;
|
|
2930
3028
|
result.aborted = abortedRows.map((row) => ({
|
|
2931
3029
|
conversationId: row.conversation_id,
|
|
@@ -2934,7 +3032,14 @@ export const aiConversations: AiConversationService = {
|
|
|
2934
3032
|
seq: Number(row.live_seq) + 1,
|
|
2935
3033
|
}));
|
|
2936
3034
|
|
|
3035
|
+
// History tells a stop from a turn that failed or whose wait expired, as it does for turns that end on their own.
|
|
3036
|
+
const stopped = new Set(abortedRows.filter((row) => row.stopped).map((row) => row.id));
|
|
2937
3037
|
for (const finalized of [...result.failed, ...result.aborted]) {
|
|
3038
|
+
await recordTurnEnd(sql, {
|
|
3039
|
+
conversationId: finalized.conversationId,
|
|
3040
|
+
turnId: finalized.turnId,
|
|
3041
|
+
reason: stopped.has(finalized.turnId) ? "aborted" : "error",
|
|
3042
|
+
});
|
|
2938
3043
|
await sql`
|
|
2939
3044
|
UPDATE ai.pending_actions
|
|
2940
3045
|
SET status = 'aborted', resolved_at = COALESCE(resolved_at, now())
|
|
@@ -3286,7 +3391,7 @@ export const aiConversations: AiConversationService = {
|
|
|
3286
3391
|
append: async (message, opts) => {
|
|
3287
3392
|
// Initial input and durable steering are already persisted transactionally before Nessi appends them.
|
|
3288
3393
|
if (message.role === "user") return;
|
|
3289
|
-
const meta = toolMessageMeta(message, input.toolPresentations, input.rejectedToolCallIds);
|
|
3394
|
+
const meta = toolMessageMeta(message, input.toolPresentations, input.rejectedToolCallIds, input.approvedToolCallIds);
|
|
3290
3395
|
|
|
3291
3396
|
if (input.turnId && input.leaseOwner) {
|
|
3292
3397
|
const appended = await appendTurnOwnedMessage({
|
package/src/ai/stream.ts
CHANGED
|
@@ -94,6 +94,8 @@ const turnSnapshotFromActive = (active: NonNullable<Awaited<ReturnType<typeof ai
|
|
|
94
94
|
blocks: [...active.liveBlocks],
|
|
95
95
|
modelProfileId: active.turn.modelProfileId,
|
|
96
96
|
createdAt: active.turn.createdAt,
|
|
97
|
+
actionWaitMs: active.actionWaitMs,
|
|
98
|
+
waitingSince: active.waitingSince,
|
|
97
99
|
});
|
|
98
100
|
|
|
99
101
|
/** Initial history window; older messages load on demand while scrolling up. */
|
package/src/ai/timeline.ts
CHANGED
|
@@ -12,9 +12,9 @@ export type AiAssistantTimelineItem = {
|
|
|
12
12
|
/** The entry whose message-actions row (copy/retry/fork) is shown. */
|
|
13
13
|
actionEntry: AiStoredMessage | null;
|
|
14
14
|
/**
|
|
15
|
-
*
|
|
16
|
-
*
|
|
17
|
-
* message
|
|
15
|
+
* Work time of the loop: wall time minus time spent waiting for user actions,
|
|
16
|
+
* from the loop's durable timing. Without timing, the time from the user
|
|
17
|
+
* message to the last persisted message.
|
|
18
18
|
*/
|
|
19
19
|
workedMs: number;
|
|
20
20
|
};
|
|
@@ -32,13 +32,6 @@ export const assistantVisibleTextFromMessage = (message: Message): string => {
|
|
|
32
32
|
.trim();
|
|
33
33
|
};
|
|
34
34
|
|
|
35
|
-
export const copyTextFromAssistantEntries = (entries: AiStoredMessage[]): string =>
|
|
36
|
-
entries
|
|
37
|
-
.map((entry) => assistantVisibleTextFromMessage(entry.message))
|
|
38
|
-
.filter(Boolean)
|
|
39
|
-
.join("\n\n")
|
|
40
|
-
.trim();
|
|
41
|
-
|
|
42
35
|
const isAssistantPart = (entry: AiStoredMessage): boolean =>
|
|
43
36
|
entry.kind === "message" && (entry.message.role === "assistant" || entry.message.role === "tool_result");
|
|
44
37
|
|
|
@@ -123,7 +116,12 @@ export const buildAiMessageTimeline = (messages: AiStoredMessage[]): AiMessageTi
|
|
|
123
116
|
const startedAt =
|
|
124
117
|
loopId && lastUserEntry?.loopId === loopId ? timestampMs(lastUserEntry.createdAt) : timestampMs(entries[0]?.createdAt);
|
|
125
118
|
const finishedAt = timestampMs(entries.at(-1)?.createdAt);
|
|
126
|
-
const
|
|
119
|
+
const timing = entries.findLast((stored) => stored.loopAggregate?.timing)?.loopAggregate?.timing;
|
|
120
|
+
const workedMs = timing
|
|
121
|
+
? Math.max(0, timing.wallMs - timing.actionWaitMs)
|
|
122
|
+
: startedAt !== null && finishedAt !== null
|
|
123
|
+
? Math.max(0, finishedAt - startedAt)
|
|
124
|
+
: 0;
|
|
127
125
|
|
|
128
126
|
const blocks = [
|
|
129
127
|
...steerBlocks,
|
package/src/ai/turn-timing.ts
CHANGED
|
@@ -80,6 +80,36 @@ export function createTurnTimingRecorder(turnId: string, db = sql) {
|
|
|
80
80
|
};
|
|
81
81
|
}
|
|
82
82
|
|
|
83
|
+
/**
|
|
84
|
+
* Time a turn waited for the person: approvals and answers until they were given. A tool the browser runs by itself
|
|
85
|
+
* waits only until it starts; a secret prompt waits for its answer even though the browser claimed it. An open wait
|
|
86
|
+
* ends now.
|
|
87
|
+
*/
|
|
88
|
+
const actionWaits = (turnId: string, db: typeof sql) =>
|
|
89
|
+
db<{ start: Date; end: Date; open: boolean }[]>`SELECT a.created_at AS start,
|
|
90
|
+
COALESCE(w.end, now()) AS end, w.end IS NULL AS open
|
|
91
|
+
FROM ai.pending_actions a LEFT JOIN ai.tool_calls t ON t.turn_id=a.turn_id AND t.call_id=a.call_id
|
|
92
|
+
CROSS JOIN LATERAL (SELECT CASE WHEN a.kind='client_tool' AND a.tool_name<>'code_secret' THEN LEAST(t.started_at,a.resolved_at)
|
|
93
|
+
ELSE a.resolved_at END AS end) w
|
|
94
|
+
WHERE a.turn_id=${turnId}::uuid`;
|
|
95
|
+
|
|
96
|
+
/**
|
|
97
|
+
* Waits of a running turn by the same rules as its durable timing: `actionWaitMs` is the time already waited, and
|
|
98
|
+
* `waitingSince` the start of the wait that is still open, overlapping waits counted once.
|
|
99
|
+
*/
|
|
100
|
+
export async function activeTurnWaits(turnId: string, db = sql): Promise<{ actionWaitMs: number; waitingSince: string | null }> {
|
|
101
|
+
const rows = await actionWaits(turnId, db);
|
|
102
|
+
const waits = union(rows.map((x) => ({ start: new Date(x.start).getTime(), end: new Date(x.end).getTime() })));
|
|
103
|
+
const opens = rows.filter((x) => x.open).map((x) => new Date(x.start).getTime());
|
|
104
|
+
const openStart = opens.length > 0 ? Math.min(...opens) : null;
|
|
105
|
+
// An open wait reaches until now, so the merged wait that holds it is the last one.
|
|
106
|
+
const current = openStart === null ? undefined : waits.find((x) => x.start <= openStart && openStart <= x.end);
|
|
107
|
+
return {
|
|
108
|
+
actionWaitMs: duration(waits.filter((x) => x !== current)),
|
|
109
|
+
waitingSince: current ? new Date(current.start).toISOString() : null,
|
|
110
|
+
};
|
|
111
|
+
}
|
|
112
|
+
|
|
83
113
|
export async function withDurableTurnTiming(turnId: string, aggregate: LoopAggregate, db = sql): Promise<LoopAggregate> {
|
|
84
114
|
const [turn] = await db<
|
|
85
115
|
{ start: Date; end: Date }[]
|
|
@@ -95,9 +125,7 @@ export async function withDurableTurnTiming(turnId: string, aggregate: LoopAggre
|
|
|
95
125
|
const tools = await db<
|
|
96
126
|
{ start: Date; end: Date }[]
|
|
97
127
|
>`SELECT started_at AS start,COALESCE(completed_at,now()) AS end FROM ai.tool_calls WHERE turn_id=${turnId}::uuid AND started_at IS NOT NULL`;
|
|
98
|
-
const waits = await db
|
|
99
|
-
CASE WHEN a.kind='client_tool' THEN LEAST(COALESCE(t.started_at,a.resolved_at,now()),COALESCE(a.resolved_at,now())) ELSE COALESCE(a.resolved_at,now()) END AS end
|
|
100
|
-
FROM ai.pending_actions a LEFT JOIN ai.tool_calls t ON t.turn_id=a.turn_id AND t.call_id=a.call_id WHERE a.turn_id=${turnId}::uuid`;
|
|
128
|
+
const waits = await actionWaits(turnId, db);
|
|
101
129
|
const intervals = (rows: { start: Date; end: Date | null }[]) =>
|
|
102
130
|
rows.flatMap((x) => (x.end ? [{ start: new Date(x.start).getTime(), end: new Date(x.end).getTime() }] : []));
|
|
103
131
|
return {
|
package/src/ai/types.ts
CHANGED
|
@@ -296,7 +296,7 @@ export type AiStoredMessage = {
|
|
|
296
296
|
trigger: "scheduled" | "manual";
|
|
297
297
|
};
|
|
298
298
|
toolPresentations?: Record<string, AiToolPresentation>;
|
|
299
|
-
toolOutcomes?: Record<string, "rejected">;
|
|
299
|
+
toolOutcomes?: Record<string, "rejected" | "approved">;
|
|
300
300
|
} | null;
|
|
301
301
|
/** Private owner feedback for this rendered assistant response. Never enters model context. */
|
|
302
302
|
feedback?: AiMessageFeedback | null;
|
|
@@ -864,7 +864,15 @@ export type AiConversationService = {
|
|
|
864
864
|
getLatestTurn(input: { conversationId: string }): Promise<AiTurn | null>;
|
|
865
865
|
getTurn(input: { conversationId: string; turnId: string }): Promise<AiTurn | null>;
|
|
866
866
|
getTurnByShortId(input: { conversationId: string; shortId: string }): Promise<AiTurn | null>;
|
|
867
|
-
getActiveTurn(input: { conversationId: string }): Promise<{
|
|
867
|
+
getActiveTurn(input: { conversationId: string }): Promise<{
|
|
868
|
+
turn: AiTurn;
|
|
869
|
+
liveBlocks: AiTurnBlock[];
|
|
870
|
+
liveSeq: number;
|
|
871
|
+
/** Time spent waiting for answered user actions. */
|
|
872
|
+
actionWaitMs: number;
|
|
873
|
+
/** Start of the oldest unanswered user action, if the turn waits now. */
|
|
874
|
+
waitingSince: string | null;
|
|
875
|
+
} | null>;
|
|
868
876
|
/**
|
|
869
877
|
* Claim a turn attempt. Increments attempt and takes the lease atomically.
|
|
870
878
|
* `from: "queue"` claims queued or lease-expired running turns; `from: "waiting"`
|
|
@@ -944,6 +952,8 @@ export type AiConversationService = {
|
|
|
944
952
|
toolPresentations?: ReadonlyMap<string, AiToolPresentation>;
|
|
945
953
|
/** Mutable call-id set read only when a rejected tool result is persisted. */
|
|
946
954
|
rejectedToolCallIds?: ReadonlySet<string>;
|
|
955
|
+
/** Mutable call-id set read only when the result of a call the user approved is persisted. */
|
|
956
|
+
approvedToolCallIds?: ReadonlySet<string>;
|
|
947
957
|
}): SessionStore;
|
|
948
958
|
};
|
|
949
959
|
|