@vellumai/assistant 0.11.7-staging.1 → 0.11.7
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/node_modules/@vellumai/ces-client/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
- package/node_modules/@vellumai/ces-client/src/http-credentials.ts +161 -0
- package/node_modules/@vellumai/ces-client/src/index.ts +1 -0
- package/node_modules/@vellumai/gateway-client/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
- package/node_modules/@vellumai/service-contracts/src/rpc.ts +174 -0
- package/openapi.yaml +1 -1
- package/package.json +1 -1
- package/scripts/sync-llm-catalog.ts +6 -0
- package/src/__tests__/credential-record-write-through.test.ts +78 -0
- package/src/__tests__/delete-propagation.test.ts +92 -2
- package/src/__tests__/edit-propagation.test.ts +42 -2
- package/src/__tests__/llm-catalog-parity.test.ts +4 -0
- package/src/__tests__/oauth-connect-orchestrator.test.ts +95 -0
- package/src/__tests__/provider-catalog-visibility.test.ts +17 -0
- package/src/__tests__/provider-platform-proxy-integration.test.ts +8 -1
- package/src/calls/__tests__/voice-session-bridge.test.ts +37 -0
- package/src/calls/voice-session-bridge.ts +13 -0
- package/src/config/feature-flag-registry.json +16 -0
- package/src/config/schemas/llm.ts +4 -4
- package/src/live-voice/__tests__/live-voice-session-telemetry.test.ts +64 -0
- package/src/live-voice/live-voice-session.ts +11 -1
- package/src/messaging/read-provider-metadata.ts +32 -0
- package/src/oauth/connect-orchestrator.ts +16 -0
- package/src/oauth/seed-providers.ts +1 -0
- package/src/persistence/conversation-crud.ts +3 -1
- package/src/providers/__tests__/provider-secret-catalog.test.ts +1 -0
- package/src/providers/__tests__/vellum-connection-routing.test.ts +21 -3
- package/src/providers/inference/adapter-factory.ts +15 -0
- package/src/providers/inference/auth.ts +14 -14
- package/src/providers/model-catalog.ts +25 -0
- package/src/providers/platform-proxy/constants.ts +5 -0
- package/src/providers/provider-secret-catalog.ts +3 -2
- package/src/providers/vellum/client.ts +29 -0
- package/src/providers/vellum-model-routing.test.ts +2 -0
- package/src/providers/vellum-model-routing.ts +3 -3
- package/src/runtime/routes/inbound-message-handler.ts +26 -39
- package/src/runtime/routes/inbound-stages/edit-intercept.ts +20 -2
- package/src/runtime/routes/migration-routes.ts +3 -1
- package/src/security/ces-rpc-record-backend.ts +123 -0
- package/src/security/secure-keys.ts +15 -0
- package/src/tools/credentials/metadata-store.ts +65 -16
- package/src/tools/credentials/store.ts +2 -0
- package/src/watch/__tests__/watch-retro.test.ts +48 -1
- package/src/watch/watch-retro.ts +46 -4
|
@@ -64,6 +64,7 @@ import {
|
|
|
64
64
|
type SlackMessageMetadata,
|
|
65
65
|
writeSlackMetadata,
|
|
66
66
|
} from "../../messaging/providers/slack/message-metadata.js";
|
|
67
|
+
import { mergeProviderMessageMetadata } from "../../messaging/read-provider-metadata.js";
|
|
67
68
|
import { MESSAGE_PREVIEW_MAX_LENGTH } from "../../notifications/notification-utils.js";
|
|
68
69
|
import {
|
|
69
70
|
attachInlineAttachmentToMessage,
|
|
@@ -94,6 +95,7 @@ import { upsertBinding } from "../../persistence/external-conversation-store.js"
|
|
|
94
95
|
import type { ContentBlock } from "../../providers/types.js";
|
|
95
96
|
import { checkIngressForSecrets } from "../../security/secret-ingress.js";
|
|
96
97
|
import { canonicalizeInboundIdentity } from "../../util/canonicalize-identity.js";
|
|
98
|
+
import { safeParseRecord } from "../../util/json.js";
|
|
97
99
|
import { getLogger } from "../../util/logger.js";
|
|
98
100
|
import { truncate } from "../../util/truncate.js";
|
|
99
101
|
import {
|
|
@@ -567,43 +569,15 @@ export async function handleChannelInbound({
|
|
|
567
569
|
return { accepted: true, deleted: false };
|
|
568
570
|
}
|
|
569
571
|
|
|
570
|
-
// Merge deletedAt into the existing slackMeta sub-key
|
|
571
|
-
//
|
|
572
|
-
//
|
|
573
|
-
//
|
|
574
|
-
//
|
|
572
|
+
// Merge deletedAt into the existing slackMeta sub-key when the row has
|
|
573
|
+
// one, so the Slack transcript renderer keeps seeing its own envelope.
|
|
574
|
+
// A row without it (a legacy pre-enrichment row) stamps the neutral
|
|
575
|
+
// shape instead: readProviderMetadata serves either to every
|
|
576
|
+
// channel-agnostic reader, so no delete goes unmarked over a metadata
|
|
577
|
+
// accident.
|
|
575
578
|
const row = getMessageById(original.messageId);
|
|
576
|
-
|
|
577
|
-
|
|
578
|
-
{
|
|
579
|
-
conversationExternalId,
|
|
580
|
-
deletedMessageTs,
|
|
581
|
-
messageId: original.messageId,
|
|
582
|
-
},
|
|
583
|
-
"Stored Slack message has no metadata; skipping delete marker",
|
|
584
|
-
);
|
|
585
|
-
return { accepted: true, deleted: false };
|
|
586
|
-
}
|
|
587
|
-
|
|
588
|
-
let parentMetadata: Record<string, unknown>;
|
|
589
|
-
try {
|
|
590
|
-
const parsed = JSON.parse(row.metadata) as unknown;
|
|
591
|
-
if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) {
|
|
592
|
-
parentMetadata = parsed as Record<string, unknown>;
|
|
593
|
-
} else {
|
|
594
|
-
parentMetadata = {};
|
|
595
|
-
}
|
|
596
|
-
} catch {
|
|
597
|
-
log.debug(
|
|
598
|
-
{
|
|
599
|
-
conversationExternalId,
|
|
600
|
-
deletedMessageTs,
|
|
601
|
-
messageId: original.messageId,
|
|
602
|
-
},
|
|
603
|
-
"Failed to parse stored metadata; skipping delete marker",
|
|
604
|
-
);
|
|
605
|
-
return { accepted: true, deleted: false };
|
|
606
|
-
}
|
|
579
|
+
const parentMetadata: Record<string, unknown> =
|
|
580
|
+
row?.metadata != null ? safeParseRecord(row.metadata) : {};
|
|
607
581
|
|
|
608
582
|
const existingSlackMeta =
|
|
609
583
|
typeof parentMetadata.slackMeta === "string"
|
|
@@ -611,15 +585,28 @@ export async function handleChannelInbound({
|
|
|
611
585
|
: null;
|
|
612
586
|
|
|
613
587
|
if (!existingSlackMeta) {
|
|
614
|
-
|
|
588
|
+
const providerMeta = mergeProviderMessageMetadata(
|
|
589
|
+
row?.metadata ?? null,
|
|
590
|
+
{
|
|
591
|
+
source: sourceChannel,
|
|
592
|
+
conversationExternalId,
|
|
593
|
+
messageId: deletedMessageTs,
|
|
594
|
+
...(sourceMetadata?.threadId
|
|
595
|
+
? { threadId: sourceMetadata.threadId }
|
|
596
|
+
: {}),
|
|
597
|
+
},
|
|
598
|
+
{ deletedAt: Date.now() },
|
|
599
|
+
);
|
|
600
|
+
updateMessageMetadata(original.messageId, { providerMeta });
|
|
601
|
+
log.info(
|
|
615
602
|
{
|
|
616
603
|
conversationExternalId,
|
|
617
604
|
deletedMessageTs,
|
|
618
605
|
messageId: original.messageId,
|
|
619
606
|
},
|
|
620
|
-
"
|
|
607
|
+
"Marked message deleted via neutral metadata",
|
|
621
608
|
);
|
|
622
|
-
return { accepted: true, deleted:
|
|
609
|
+
return { accepted: true, deleted: true, messageId: original.messageId };
|
|
623
610
|
}
|
|
624
611
|
|
|
625
612
|
const updatedSlackMeta = mergeSlackMetadata(existingSlackMeta, {
|
|
@@ -20,10 +20,10 @@ import {
|
|
|
20
20
|
mergeSlackMetadata,
|
|
21
21
|
readSlackMetadata,
|
|
22
22
|
} from "../../../messaging/providers/slack/message-metadata.js";
|
|
23
|
+
import { mergeProviderMessageMetadata } from "../../../messaging/read-provider-metadata.js";
|
|
23
24
|
import type { MessageRow } from "../../../persistence/conversation-crud.js";
|
|
24
25
|
import {
|
|
25
26
|
getMessageById,
|
|
26
|
-
updateMessageContent,
|
|
27
27
|
updateMessageContentAndMetadata,
|
|
28
28
|
} from "../../../persistence/conversation-crud.js";
|
|
29
29
|
import {
|
|
@@ -31,6 +31,7 @@ import {
|
|
|
31
31
|
findMessageBySourceId,
|
|
32
32
|
recordInbound,
|
|
33
33
|
} from "../../../persistence/delivery-crud.js";
|
|
34
|
+
import { markProcessed } from "../../../persistence/delivery-status.js";
|
|
34
35
|
import { enqueueLexicalIndexForMessage } from "../../../persistence/job-handlers/message-lexical.js";
|
|
35
36
|
import { stringifyMessageContent } from "../../../persistence/message-content.js";
|
|
36
37
|
import { safeParseRecord } from "../../../util/json.js";
|
|
@@ -182,6 +183,7 @@ export async function handleEditIntercept(
|
|
|
182
183
|
},
|
|
183
184
|
"Edit text unchanged; skipping update",
|
|
184
185
|
);
|
|
186
|
+
markProcessed(editResult.eventId);
|
|
185
187
|
return {
|
|
186
188
|
accepted: true,
|
|
187
189
|
duplicate: false,
|
|
@@ -203,13 +205,29 @@ export async function handleEditIntercept(
|
|
|
203
205
|
newContent,
|
|
204
206
|
});
|
|
205
207
|
} else {
|
|
206
|
-
|
|
208
|
+
// Every channel marks its edits. Slack's envelope carries the extra
|
|
209
|
+
// fields its own renderer needs; the rest stamp the neutral shape that
|
|
210
|
+
// readProviderMetadata serves to every channel-agnostic reader.
|
|
211
|
+
const providerMeta = mergeProviderMessageMetadata(
|
|
212
|
+
existingRow?.metadata ?? null,
|
|
213
|
+
{
|
|
214
|
+
source: sourceChannel,
|
|
215
|
+
conversationExternalId,
|
|
216
|
+
messageId: sourceMessageId,
|
|
217
|
+
...(sourceThreadId ? { threadId: sourceThreadId } : {}),
|
|
218
|
+
},
|
|
219
|
+
{ editedAt: Date.now() },
|
|
220
|
+
);
|
|
221
|
+
updateMessageContentAndMetadata(original.messageId, newContent, {
|
|
222
|
+
providerMeta,
|
|
223
|
+
});
|
|
207
224
|
}
|
|
208
225
|
// The edit changed searchable text (the no-op guard above already returned
|
|
209
226
|
// for identical content) and this path bypasses the `addMessage` persist
|
|
210
227
|
// path, so reindex the message into the lexical index — the idempotent
|
|
211
228
|
// upsert replaces the stale Qdrant point with the edited content.
|
|
212
229
|
enqueueLexicalIndexForMessage(original.messageId);
|
|
230
|
+
markProcessed(editResult.eventId);
|
|
213
231
|
log.info(
|
|
214
232
|
{ assistantId, sourceMessageId, messageId: original.messageId },
|
|
215
233
|
"Updated message content from edited_message",
|
|
@@ -44,6 +44,7 @@ import {
|
|
|
44
44
|
} from "../../security/secure-keys.js";
|
|
45
45
|
import {
|
|
46
46
|
getCredentialMetadata,
|
|
47
|
+
persistCredentialMetadata,
|
|
47
48
|
upsertCredentialMetadata,
|
|
48
49
|
} from "../../tools/credentials/metadata-store.js";
|
|
49
50
|
import { getLogger } from "../../util/logger.js";
|
|
@@ -156,7 +157,8 @@ export async function reconcileVellumMetadataFromCes(warningSink: {
|
|
|
156
157
|
if (getCredentialMetadata("vellum", field)) {
|
|
157
158
|
continue;
|
|
158
159
|
}
|
|
159
|
-
upsertCredentialMetadata("vellum", field, {});
|
|
160
|
+
const metadata = upsertCredentialMetadata("vellum", field, {});
|
|
161
|
+
await persistCredentialMetadata(metadata);
|
|
160
162
|
log.info(
|
|
161
163
|
{ field },
|
|
162
164
|
"Reconciled vellum:* metadata entry from CES after import",
|
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* CES RPC backend for non-secret credential records (identity + policy).
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import type { CredentialRecord } from "@vellumai/service-contracts/credential-rpc";
|
|
6
|
+
import { CesRpcMethod } from "@vellumai/service-contracts/credential-rpc";
|
|
7
|
+
|
|
8
|
+
import type { CesClient } from "../credential-execution/client.js";
|
|
9
|
+
import { getLogger } from "../util/logger.js";
|
|
10
|
+
|
|
11
|
+
const log = getLogger("ces-rpc-record-backend");
|
|
12
|
+
|
|
13
|
+
export interface CredentialRecordBackend {
|
|
14
|
+
isAvailable(): boolean;
|
|
15
|
+
get(account: string): Promise<CredentialRecord | undefined>;
|
|
16
|
+
set(account: string, record: CredentialRecord): Promise<boolean>;
|
|
17
|
+
delete(account: string): Promise<"deleted" | "not-found" | "error">;
|
|
18
|
+
/**
|
|
19
|
+
* List all records. Returns `null` when CES cannot be reached so callers
|
|
20
|
+
* can distinguish an outage from an empty catalog.
|
|
21
|
+
*/
|
|
22
|
+
list(): Promise<Array<{
|
|
23
|
+
account: string;
|
|
24
|
+
record: CredentialRecord;
|
|
25
|
+
}> | null>;
|
|
26
|
+
bulkSet(
|
|
27
|
+
records: Array<{ account: string; record: CredentialRecord }>,
|
|
28
|
+
): Promise<Array<{ account: string; ok: boolean }>>;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
export class CesRpcRecordBackend implements CredentialRecordBackend {
|
|
32
|
+
constructor(private readonly client: CesClient) {}
|
|
33
|
+
|
|
34
|
+
isAvailable(): boolean {
|
|
35
|
+
return this.client.isReady();
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
async get(account: string): Promise<CredentialRecord | undefined> {
|
|
39
|
+
if (!this.isAvailable()) {
|
|
40
|
+
return undefined;
|
|
41
|
+
}
|
|
42
|
+
try {
|
|
43
|
+
const result = await this.client.call(CesRpcMethod.GetCredentialRecord, {
|
|
44
|
+
account,
|
|
45
|
+
});
|
|
46
|
+
return result.found ? result.record : undefined;
|
|
47
|
+
} catch (err) {
|
|
48
|
+
log.warn({ err, account }, "CES RPC credential record get failed");
|
|
49
|
+
return undefined;
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
async set(account: string, record: CredentialRecord): Promise<boolean> {
|
|
54
|
+
if (!this.isAvailable()) {
|
|
55
|
+
return false;
|
|
56
|
+
}
|
|
57
|
+
try {
|
|
58
|
+
const result = await this.client.call(CesRpcMethod.SetCredentialRecord, {
|
|
59
|
+
account,
|
|
60
|
+
record,
|
|
61
|
+
});
|
|
62
|
+
return result.ok;
|
|
63
|
+
} catch (err) {
|
|
64
|
+
log.warn({ err, account }, "CES RPC credential record set failed");
|
|
65
|
+
return false;
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
async delete(
|
|
70
|
+
account: string,
|
|
71
|
+
): Promise<"deleted" | "not-found" | "error"> {
|
|
72
|
+
if (!this.isAvailable()) {
|
|
73
|
+
return "error";
|
|
74
|
+
}
|
|
75
|
+
try {
|
|
76
|
+
const result = await this.client.call(
|
|
77
|
+
CesRpcMethod.DeleteCredentialRecord,
|
|
78
|
+
{ account },
|
|
79
|
+
);
|
|
80
|
+
return result.result;
|
|
81
|
+
} catch (err) {
|
|
82
|
+
log.warn({ err, account }, "CES RPC credential record delete failed");
|
|
83
|
+
return "error";
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
async list(): Promise<Array<{
|
|
88
|
+
account: string;
|
|
89
|
+
record: CredentialRecord;
|
|
90
|
+
}> | null> {
|
|
91
|
+
if (!this.isAvailable()) {
|
|
92
|
+
return null;
|
|
93
|
+
}
|
|
94
|
+
try {
|
|
95
|
+
const result = await this.client.call(
|
|
96
|
+
CesRpcMethod.ListCredentialRecords,
|
|
97
|
+
{},
|
|
98
|
+
);
|
|
99
|
+
return result.records ?? [];
|
|
100
|
+
} catch (err) {
|
|
101
|
+
log.warn({ err }, "CES RPC credential record list failed");
|
|
102
|
+
return null;
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
async bulkSet(
|
|
107
|
+
records: Array<{ account: string; record: CredentialRecord }>,
|
|
108
|
+
): Promise<Array<{ account: string; ok: boolean }>> {
|
|
109
|
+
if (!this.isAvailable()) {
|
|
110
|
+
return records.map((entry) => ({ account: entry.account, ok: false }));
|
|
111
|
+
}
|
|
112
|
+
try {
|
|
113
|
+
const result = await this.client.call(
|
|
114
|
+
CesRpcMethod.BulkSetCredentialRecords,
|
|
115
|
+
{ records },
|
|
116
|
+
);
|
|
117
|
+
return result.results;
|
|
118
|
+
} catch (err) {
|
|
119
|
+
log.warn({ err }, "CES RPC credential record bulk set failed");
|
|
120
|
+
return records.map((entry) => ({ account: entry.account, ok: false }));
|
|
121
|
+
}
|
|
122
|
+
}
|
|
123
|
+
}
|
|
@@ -156,6 +156,21 @@ export function setCesClient(client: CesClient | undefined): void {
|
|
|
156
156
|
"CES client updated; resetting resolved credential backend cache",
|
|
157
157
|
);
|
|
158
158
|
_cesClientListener?.(client);
|
|
159
|
+
void attachCredentialRecordBackend(client);
|
|
160
|
+
}
|
|
161
|
+
|
|
162
|
+
async function attachCredentialRecordBackend(
|
|
163
|
+
client: CesClient | undefined,
|
|
164
|
+
): Promise<void> {
|
|
165
|
+
const { CesRpcRecordBackend } = await import("./ces-rpc-record-backend.js");
|
|
166
|
+
const { setCredentialRecordBackend } = await import(
|
|
167
|
+
"../tools/credentials/metadata-store.js"
|
|
168
|
+
);
|
|
169
|
+
if (!client) {
|
|
170
|
+
setCredentialRecordBackend(undefined);
|
|
171
|
+
return;
|
|
172
|
+
}
|
|
173
|
+
setCredentialRecordBackend(new CesRpcRecordBackend(client));
|
|
159
174
|
}
|
|
160
175
|
|
|
161
176
|
/**
|
|
@@ -1,24 +1,29 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Credential metadata store.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
* sites throughout the assistant daemon do not need to change.
|
|
4
|
+
* Reads stay on the workspace `metadata.json` file (sync). When a CES
|
|
5
|
+
* record backend is injected, upserts and deletes also write-through to
|
|
6
|
+
* CES so the CES catalog stays current.
|
|
8
7
|
*
|
|
9
|
-
*
|
|
10
|
-
*
|
|
11
|
-
* exclusively managed by the SQLite oauth-store and have been removed
|
|
12
|
-
* from this interface as of v5.
|
|
8
|
+
* Tests: `_setMetadataPath` keeps the file-backed store and skips CES
|
|
9
|
+
* write-through.
|
|
13
10
|
*/
|
|
14
11
|
|
|
15
12
|
import { join } from "node:path";
|
|
16
13
|
|
|
17
|
-
import {
|
|
14
|
+
import {
|
|
15
|
+
credentialKey,
|
|
16
|
+
StaticCredentialMetadataStore,
|
|
17
|
+
} from "@vellumai/credential-storage";
|
|
18
|
+
import type { CredentialRecord } from "@vellumai/service-contracts/credential-rpc";
|
|
18
19
|
|
|
20
|
+
import type { CredentialRecordBackend } from "../../security/ces-rpc-record-backend.js";
|
|
21
|
+
import { getLogger } from "../../util/logger.js";
|
|
19
22
|
import { getDataDir } from "../../util/platform.js";
|
|
20
23
|
import type { CredentialInjectionTemplate } from "./policy-types.js";
|
|
21
24
|
|
|
25
|
+
const log = getLogger("credential-metadata-store");
|
|
26
|
+
|
|
22
27
|
/**
|
|
23
28
|
* CredentialMetadata extends the shared StaticCredentialRecord with
|
|
24
29
|
* assistant-specific injection template fields (composeWith, valueTransform).
|
|
@@ -43,12 +48,9 @@ export interface CredentialMetadata {
|
|
|
43
48
|
// Singleton store instance
|
|
44
49
|
// ---------------------------------------------------------------------------
|
|
45
50
|
|
|
46
|
-
/**
|
|
47
|
-
* Lazily initialised store instance. The path is determined on first access
|
|
48
|
-
* (or overridden via `_setMetadataPath` for tests).
|
|
49
|
-
*/
|
|
50
51
|
let _store: StaticCredentialMetadataStore | undefined;
|
|
51
52
|
let _overridePath: string | null = null;
|
|
53
|
+
let _recordBackend: CredentialRecordBackend | undefined;
|
|
52
54
|
|
|
53
55
|
function getStore(): StaticCredentialMetadataStore {
|
|
54
56
|
if (!_store) {
|
|
@@ -59,6 +61,48 @@ function getStore(): StaticCredentialMetadataStore {
|
|
|
59
61
|
return _store;
|
|
60
62
|
}
|
|
61
63
|
|
|
64
|
+
function toRecord(metadata: CredentialMetadata): CredentialRecord {
|
|
65
|
+
return {
|
|
66
|
+
credentialId: metadata.credentialId,
|
|
67
|
+
service: metadata.service,
|
|
68
|
+
field: metadata.field,
|
|
69
|
+
allowedTools: metadata.allowedTools,
|
|
70
|
+
allowedDomains: metadata.allowedDomains,
|
|
71
|
+
usageDescription: metadata.usageDescription,
|
|
72
|
+
alias: metadata.alias,
|
|
73
|
+
injectionTemplates: metadata.injectionTemplates,
|
|
74
|
+
createdAt: metadata.createdAt,
|
|
75
|
+
updatedAt: metadata.updatedAt,
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function persistRecord(metadata: CredentialMetadata): Promise<void> {
|
|
80
|
+
if (!_recordBackend || _overridePath) {
|
|
81
|
+
return;
|
|
82
|
+
}
|
|
83
|
+
const account = credentialKey(metadata.service, metadata.field);
|
|
84
|
+
const ok = await _recordBackend.set(account, toRecord(metadata));
|
|
85
|
+
if (!ok) {
|
|
86
|
+
log.warn(
|
|
87
|
+
{ service: metadata.service, field: metadata.field },
|
|
88
|
+
"Failed to persist credential record to CES",
|
|
89
|
+
);
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/** Await CES write-through for a record already in the local file store. */
|
|
94
|
+
export async function persistCredentialMetadata(
|
|
95
|
+
metadata: CredentialMetadata,
|
|
96
|
+
): Promise<void> {
|
|
97
|
+
await persistRecord(metadata);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
export function setCredentialRecordBackend(
|
|
101
|
+
backend: CredentialRecordBackend | undefined,
|
|
102
|
+
): void {
|
|
103
|
+
_recordBackend = backend;
|
|
104
|
+
}
|
|
105
|
+
|
|
62
106
|
// ---------------------------------------------------------------------------
|
|
63
107
|
// Public API - unchanged signatures, delegates to shared store
|
|
64
108
|
// ---------------------------------------------------------------------------
|
|
@@ -89,7 +133,9 @@ export function upsertCredentialMetadata(
|
|
|
89
133
|
injectionTemplates?: CredentialInjectionTemplate[] | null;
|
|
90
134
|
},
|
|
91
135
|
): CredentialMetadata {
|
|
92
|
-
|
|
136
|
+
const record = getStore().upsert(service, field, policy) as CredentialMetadata;
|
|
137
|
+
void persistRecord(record);
|
|
138
|
+
return record;
|
|
93
139
|
}
|
|
94
140
|
|
|
95
141
|
/**
|
|
@@ -127,13 +173,16 @@ export function deleteCredentialMetadata(
|
|
|
127
173
|
service: string,
|
|
128
174
|
field: string,
|
|
129
175
|
): boolean {
|
|
130
|
-
|
|
176
|
+
const deleted = getStore().delete(service, field);
|
|
177
|
+
if (deleted && _recordBackend && !_overridePath) {
|
|
178
|
+
void _recordBackend.delete(credentialKey(service, field));
|
|
179
|
+
}
|
|
180
|
+
return deleted;
|
|
131
181
|
}
|
|
132
182
|
|
|
133
183
|
/** @internal Test-only: override the metadata file path. */
|
|
134
184
|
export function _setMetadataPath(path: string | null): void {
|
|
135
185
|
_overridePath = path;
|
|
136
|
-
// Reset the store so it picks up the new path
|
|
137
186
|
if (_store) {
|
|
138
187
|
if (path) {
|
|
139
188
|
_store.setPath(path);
|
|
@@ -37,6 +37,7 @@ import {
|
|
|
37
37
|
import { getLogger } from "../../util/logger.js";
|
|
38
38
|
import {
|
|
39
39
|
assertMetadataWritable,
|
|
40
|
+
persistCredentialMetadata,
|
|
40
41
|
upsertCredentialMetadata,
|
|
41
42
|
} from "./metadata-store.js";
|
|
42
43
|
import type { CredentialInjectionTemplate } from "./policy-types.js";
|
|
@@ -161,6 +162,7 @@ export async function storeCredentialValue(
|
|
|
161
162
|
allowedDomains: input.allowedDomains,
|
|
162
163
|
injectionTemplates: input.injectionTemplates,
|
|
163
164
|
});
|
|
165
|
+
await persistCredentialMetadata(metadata);
|
|
164
166
|
const { syncManualTokenConnection } =
|
|
165
167
|
await import("../../oauth/manual-token-connection.js");
|
|
166
168
|
await syncManualTokenConnection(service);
|
|
@@ -247,6 +247,51 @@ describe("watch retrospective", () => {
|
|
|
247
247
|
);
|
|
248
248
|
});
|
|
249
249
|
|
|
250
|
+
test("puts the report last in the turn, after the skill it hands off to", async () => {
|
|
251
|
+
const summary = recordSession(["renaming the export"]);
|
|
252
|
+
const { calls, dispatch } = recordingDispatch();
|
|
253
|
+
|
|
254
|
+
await runWatchRetro(summary, { dispatch });
|
|
255
|
+
|
|
256
|
+
const { prompt } = calls[0]!;
|
|
257
|
+
// The report is what the user is shown when a session ends, so it has to
|
|
258
|
+
// be the turn's last prose. A client renders the final text block as the
|
|
259
|
+
// response and folds everything before it into collapsed intermediate
|
|
260
|
+
// work, so a `skill_load` issued after the report demotes the report to
|
|
261
|
+
// an "Earlier activity" row the user has to unfold to read.
|
|
262
|
+
expect(prompt).toContain("Load the `skill-management` skill first");
|
|
263
|
+
expect(
|
|
264
|
+
prompt.indexOf("Load the `skill-management` skill first"),
|
|
265
|
+
).toBeLessThan(prompt.indexOf("What I need from you"));
|
|
266
|
+
expect(prompt).toContain("That report is the last thing you do this turn");
|
|
267
|
+
// A sign-off is another text block after the report, which puts the report
|
|
268
|
+
// back on the wrong side of the same rule, so it is refused by name.
|
|
269
|
+
expect(prompt).toContain("no sign-off");
|
|
270
|
+
expect(prompt).toContain("no further tool call");
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
test("writes the report even when the skill it hands off to will not load", async () => {
|
|
274
|
+
const summary = recordSession(["filing the receipt"]);
|
|
275
|
+
const { calls, dispatch } = recordingDispatch();
|
|
276
|
+
|
|
277
|
+
await runWatchRetro(summary, { dispatch });
|
|
278
|
+
|
|
279
|
+
const { prompt } = calls[0]!;
|
|
280
|
+
// `skill-management` is a selector, and a managed or workspace skill of the
|
|
281
|
+
// same id replaces the bundled one in the catalog. Putting the load ahead
|
|
282
|
+
// of the report is what lets a shadow, or the refusal a clientless wake
|
|
283
|
+
// gives an inline-command load, land before the user has been told
|
|
284
|
+
// anything. Neither may become the retro: the session is recorded and the
|
|
285
|
+
// account of it is what the user is owed.
|
|
286
|
+
expect(prompt).toContain(
|
|
287
|
+
"Write the report below whether or not that load succeeds",
|
|
288
|
+
);
|
|
289
|
+
expect(prompt).toContain("do not report on the load and do not retry it");
|
|
290
|
+
// The failure is still named, so a missing handoff does not read as a
|
|
291
|
+
// report that simply chose not to ask about authoring.
|
|
292
|
+
expect(prompt).toContain("could not open the skill-authoring flow");
|
|
293
|
+
});
|
|
294
|
+
|
|
250
295
|
test("authors nothing until the user has confirmed", async () => {
|
|
251
296
|
const summary = recordSession(["exporting the sheet"]);
|
|
252
297
|
const { calls, dispatch } = recordingDispatch();
|
|
@@ -605,7 +650,9 @@ describe("watch retrospective", () => {
|
|
|
605
650
|
expect(payload).toBeGreaterThan(opened);
|
|
606
651
|
expect(payload).toBeLessThan(closed);
|
|
607
652
|
// The retrospective's own instructions sit outside it.
|
|
608
|
-
expect(
|
|
653
|
+
expect(
|
|
654
|
+
prompt.indexOf("Load the `skill-management` skill first"),
|
|
655
|
+
).toBeGreaterThan(closed);
|
|
609
656
|
|
|
610
657
|
// One real envelope, so nothing in the recording can pass for a second.
|
|
611
658
|
expect(prompt.match(/<external_content/g)).toHaveLength(1);
|
package/src/watch/watch-retro.ts
CHANGED
|
@@ -76,16 +76,58 @@ const WATCH_RETRO_WAKE_SOURCE = "watch-retro";
|
|
|
76
76
|
* gap between those two is the whole risk of turning a demonstration into a
|
|
77
77
|
* skill. `skill-management` will not scaffold until that step is settled
|
|
78
78
|
* either, so an unasked one stalls the flow it was meant to feed.
|
|
79
|
+
*
|
|
80
|
+
* **The skill loads before the report is written, and nothing follows the
|
|
81
|
+
* report.** This is about where the report lands on screen, not about the
|
|
82
|
+
* order the work happens in. A client renders an assistant turn as its final
|
|
83
|
+
* prose plus the intermediate work that led there, and the web transcript
|
|
84
|
+
* collapses that intermediate half into an "Earlier activity" disclosure that
|
|
85
|
+
* a settled turn shows closed (`finalResponseGroupIndex` in
|
|
86
|
+
* `transcript-message-body.tsx`). Everything before the turn's last non-empty
|
|
87
|
+
* text block is intermediate by that rule, including prose. Writing the report
|
|
88
|
+
* first and loading the skill after it puts a `skill_load` and whatever the
|
|
89
|
+
* model says once the skill is in hand *after* the report, which demotes the
|
|
90
|
+
* report to intermediate work and hides it behind a collapsed row next to the
|
|
91
|
+
* reasoning rows. The user presses stop on a session and is shown a folded-up
|
|
92
|
+
* link where the account of it should be.
|
|
93
|
+
*
|
|
94
|
+
* So the load comes first and the report is last. `skill-management` opens on
|
|
95
|
+
* "Ask before doing anything", so a turn that loads it and then asks is
|
|
96
|
+
* following it rather than jumping the flow, and the report is the alignment
|
|
97
|
+
* its first step calls for. The instruction that nothing follows the report is
|
|
98
|
+
* as load-bearing as the ordering: one trailing line of sign-off is another
|
|
99
|
+
* text block after it, and the report is intermediate again.
|
|
100
|
+
*
|
|
101
|
+
* **The report does not depend on the load succeeding.** `skill-management` is
|
|
102
|
+
* a selector, not a fixed definition: `loadSkillCatalog` lets a managed or
|
|
103
|
+
* workspace skill of the same id replace the bundled one, and
|
|
104
|
+
* `resolveSkillSelector` hands back whichever entry won. So the load can come
|
|
105
|
+
* back as someone else's skill, or as a refusal, and putting it ahead of the
|
|
106
|
+
* report is what makes either one land before the user has been told anything.
|
|
107
|
+
* A refusal is the likelier of the two here: this wake is `clientless`, and an
|
|
108
|
+
* inline-command load with no human present is denied outright
|
|
109
|
+
* (`permissions/checker.ts`, `isDynamicSkillLoadInvocation`). The bundled skill
|
|
110
|
+
* carries no such expansions, so the ordinary path is unaffected, but a shadow
|
|
111
|
+
* that carries them is denied on arrival.
|
|
112
|
+
*
|
|
113
|
+
* Neither outcome is allowed to become the retro. The session was recorded, the
|
|
114
|
+
* timeline is already in this prompt, and the account of it is the one thing the
|
|
115
|
+
* user is owed for having pressed stop; a permission error where that account
|
|
116
|
+
* should be is the same empty thread the `surfaceConversation` guard exists to
|
|
117
|
+
* prevent, just with prose in it. So the instructions write the report either
|
|
118
|
+
* way and say the handoff did not happen, rather than reporting on the load.
|
|
79
119
|
*/
|
|
80
|
-
const RETRO_INSTRUCTIONS = `
|
|
120
|
+
const RETRO_INSTRUCTIONS = `Load the \`skill-management\` skill first, before you write anything, and follow it. Do not author or scaffold a skill until the four points that step names are settled: the report below is the alignment its first step calls for.
|
|
121
|
+
|
|
122
|
+
Write the report below whether or not that load succeeds. If it fails, is refused, or comes back as something other than the skill-management flow, do not report on the load and do not retry it: write the report, and end it with one line saying you could not open the skill-authoring flow, so the user knows the handoff is the part that did not happen.
|
|
123
|
+
|
|
124
|
+
Then write back to the user in two sections, in this order, both as level-2 headings. That report is the last thing you do this turn, and nothing follows it: no sign-off, no note about what you loaded, no further tool call. Their answers come back as an ordinary reply and the flow picks up from there.
|
|
81
125
|
|
|
82
126
|
First, "What I need from you". The questions you cannot answer from the recording, numbered, most consequential first. Each one concrete enough to answer in a sentence, and each one about something you are genuinely guessing at: a value you could not read, a choice whose rule you could not infer, a step you only saw the result of. Always ask what they would say to start this task, in their own words, because the recording cannot tell you that. Ask about the done condition if it is unclear. Always confirm any destructive or irreversible step, even one the recording showed plainly: watching someone do a thing once is not agreement to have it done again unattended, and this is the one place the rule below does not apply. Otherwise do not ask them to confirm something the recording already showed you.
|
|
83
127
|
|
|
84
128
|
Second, "What I saw". Open with one sentence naming the task and what it is for, on its own and not as a list item. Then the steps in order beneath it, one line each and concrete enough to follow, carrying no purpose of their own. This is the record your questions sit on top of, so state it rather than asking about it.
|
|
85
129
|
|
|
86
|
-
Open on the first heading. No preamble, no announcing what you are about to do, no narrating which skills you are loading.
|
|
87
|
-
|
|
88
|
-
Then load the \`skill-management\` skill and follow it, treating the answers to your questions as the alignment its first step calls for. Do not author or scaffold a skill until the four points that step names are settled. Correct your reading against whatever they tell you. If they decide this is not worth keeping, say so and stop.`;
|
|
130
|
+
Open on the first heading. No preamble, no announcing what you are about to do, no narrating which skills you are loading. Correct your reading against whatever they tell you. If they decide this is not worth keeping, say so and stop.`;
|
|
89
131
|
|
|
90
132
|
/**
|
|
91
133
|
* Told to the model whenever the render was bounded, naming the bound that
|