@vellumai/assistant 0.12.3 → 0.12.4-dev.202609231620.a15f321
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/Dockerfile +7 -0
- package/README.md +12 -12
- package/docker/mousepad/90-vellum.gschema.override +4 -0
- package/node_modules/@vellumai/avatar-manifest/src/__tests__/notification-avatar.test.ts +48 -4
- package/node_modules/@vellumai/avatar-manifest/src/notification-avatar.ts +12 -8
- package/openapi.yaml +97 -4
- package/package.json +1 -1
- package/src/__tests__/approval-routes-http.test.ts +4 -2
- package/src/__tests__/call-routes-http.test.ts +4 -2
- package/src/__tests__/channel-readiness-discord.test.ts +8 -0
- package/src/__tests__/channel-readiness-slack-remote.test.ts +8 -0
- package/src/__tests__/config-loader-backfill.test.ts +1 -0
- package/src/__tests__/conversation-pairing.test.ts +10 -0
- package/src/__tests__/conversation-resurface-done.test.ts +289 -0
- package/src/__tests__/default-profile-catalog-fallback.test.ts +1 -0
- package/src/__tests__/gemini-provider.test.ts +31 -0
- package/src/__tests__/inference-profile-validation-jev.test.ts +17 -0
- package/src/__tests__/job-handler-registration-guard.test.ts +1 -0
- package/src/__tests__/llm-catalog-parity.test.ts +4 -0
- package/src/__tests__/llm-resolver-override-or-default.test.ts +43 -0
- package/src/__tests__/openai-responses-prompt-cache.test.ts +3 -1
- package/src/__tests__/platform-callback-registration.test.ts +46 -1
- package/src/__tests__/plugin-api-model-profiles.test.ts +7 -0
- package/src/__tests__/plugin-import-boundary-guard.test.ts +3 -0
- package/src/__tests__/pricing.test.ts +33 -0
- package/src/__tests__/provider-error-scenarios.test.ts +46 -0
- package/src/__tests__/provider-platform-proxy-integration.test.ts +45 -15
- package/src/__tests__/retry-fallback-escalation.test.ts +22 -0
- package/src/__tests__/runtime-attachment-metadata.test.ts +4 -2
- package/src/__tests__/scaffold-managed-skill-tool.test.ts +116 -66
- package/src/__tests__/secret-routes-platform-proxy.test.ts +1 -0
- package/src/__tests__/send-endpoint-busy.test.ts +4 -2
- package/src/__tests__/skill-load-inline-command.test.ts +35 -0
- package/src/__tests__/skill-load-tool.test.ts +42 -11
- package/src/__tests__/skill-tool-factory.test.ts +50 -2
- package/src/__tests__/steer-on-enqueue-question.test.ts +265 -34
- package/src/__tests__/validate-input.test.ts +73 -0
- package/src/__tests__/workspace-migration-159-rename-colliding-jev-profile-name.test.ts +197 -0
- package/src/api/ask-question-tool.ts +50 -0
- package/src/api/events/tool-result.ts +11 -0
- package/src/api/index.ts +7 -0
- package/src/api/requests/dictation.ts +2 -2
- package/src/api/responses/home.ts +27 -0
- package/src/avatar/notification-avatar.test.ts +42 -34
- package/src/avatar/notification-avatar.ts +1 -0
- package/src/calls/__tests__/voice-session-bridge.test.ts +63 -1
- package/src/calls/voice-escalation-judge.ts +6 -5
- package/src/calls/voice-screen-annotation.ts +44 -0
- package/src/calls/voice-session-bridge.ts +22 -0
- package/src/cli/commands/__tests__/plugins.test.ts +29 -1
- package/src/cli/commands/channel-verification-sessions.help.ts +12 -2
- package/src/cli/commands/platform/index.help.ts +2 -1
- package/src/cli/commands/platform/index.ts +8 -2
- package/src/cli/commands/plugins.ts +16 -0
- package/src/cli/lib/__tests__/inspect-plugin.test.ts +29 -0
- package/src/cli/lib/__tests__/plugin-catalog-cache.test.ts +92 -2
- package/src/cli/lib/__tests__/plugin-catalog-local.test.ts +15 -0
- package/src/cli/lib/__tests__/plugin-catalog-platform.test.ts +66 -2
- package/src/cli/lib/__tests__/plugin-catalog-resolve.test.ts +21 -0
- package/src/cli/lib/__tests__/plugin-catalog-visibility.test.ts +65 -0
- package/src/cli/lib/__tests__/plugin-details.test.ts +43 -0
- package/src/cli/lib/__tests__/plugins-install-offline.test.ts +26 -0
- package/src/cli/lib/bundled-marketplace.json +4 -4
- package/src/cli/lib/inspect-plugin.ts +5 -0
- package/src/cli/lib/plugin-catalog-cache.ts +41 -4
- package/src/cli/lib/plugin-catalog-local.ts +9 -0
- package/src/cli/lib/plugin-catalog-platform.ts +48 -9
- package/src/cli/lib/plugin-catalog-visibility.ts +41 -0
- package/src/cli/lib/plugin-details.ts +10 -7
- package/src/cli/lib/search-plugins.ts +2 -0
- package/src/config/__tests__/default-profile-catalog.test.ts +61 -1
- package/src/config/__tests__/jev-managed-profile.test.ts +82 -0
- package/src/config/__tests__/sync-gated-profiles.test.ts +71 -0
- package/src/config/bundled-skills/screen-annotation/SKILL.md +6 -3
- package/src/config/call-site-defaults.ts +17 -11
- package/src/config/default-profile-catalog.ts +118 -19
- package/src/config/default-profile-names.ts +69 -0
- package/src/config/feature-flag-registry.json +25 -9
- package/src/config/inference-profile-validation.ts +7 -6
- package/src/config/llm-resolver.ts +16 -0
- package/src/config/profile-text-generation.test.ts +9 -0
- package/src/config/schemas/call-site-catalog.ts +8 -1
- package/src/config/schemas/llm.ts +70 -9
- package/src/config/sidebar-done-gate.ts +27 -0
- package/src/config/sync-gated-profiles.ts +96 -44
- package/src/daemon/__tests__/auto-profile-router.test.ts +228 -0
- package/src/daemon/auto-profile-router.ts +220 -0
- package/src/daemon/conversation-agent-loop.ts +77 -4
- package/src/daemon/conversation-slash.ts +2 -2
- package/src/daemon/conversation-turn-finalize.ts +12 -0
- package/src/daemon/dictation-text-processing.ts +4 -4
- package/src/daemon/handlers/__tests__/conversation-history-search.test.ts +61 -0
- package/src/daemon/handlers/config-channels.ts +3 -1
- package/src/daemon/handlers/conversation-history.ts +11 -1
- package/src/daemon/handlers/conversations.ts +118 -10
- package/src/daemon/pkb-reminder-builder.test.ts +10 -3
- package/src/daemon/pkb-reminder-builder.ts +1 -1
- package/src/desktop/desktop-browser-endpoint.ts +1 -0
- package/src/home/feed-types.ts +2 -0
- package/src/inbound/platform-callback-registration.ts +19 -6
- package/src/ipc/routes/__tests__/conversation-sync-ipc-routes.test.ts +49 -1
- package/src/ipc/routes/conversation-sync-ipc-routes.ts +38 -2
- package/src/live-voice/__tests__/live-voice-agent-turn.test.ts +27 -0
- package/src/live-voice/__tests__/protocol.test.ts +22 -0
- package/src/live-voice/continuation-judge.ts +1 -1
- package/src/live-voice/live-voice-session.ts +7 -6
- package/src/live-voice/protocol.ts +16 -5
- package/src/messaging/providers/gmail/client.ts +4 -0
- package/src/notifications/README.md +11 -3
- package/src/notifications/__tests__/home-feed-side-effect.test.ts +87 -3
- package/src/notifications/conversation-pairing.ts +23 -8
- package/src/notifications/home-feed-mirror.ts +39 -0
- package/src/notifications/home-feed-side-effect.ts +46 -19
- package/src/oauth/AGENTS.md +7 -1
- package/src/permissions/question-prompter.ts +14 -4
- package/src/persistence/conversation-crud.ts +114 -3
- package/src/persistence/conversation-queries.ts +48 -3
- package/src/persistence/embeddings/__tests__/embedding-local-lifecycle.test.ts +228 -32
- package/src/persistence/embeddings/embedding-backend-shutdown.test.ts +47 -0
- package/src/persistence/embeddings/embedding-backend.ts +23 -41
- package/src/persistence/embeddings/embedding-gemini.test.ts +17 -0
- package/src/persistence/embeddings/embedding-gemini.ts +3 -1
- package/src/persistence/embeddings/embedding-local.ts +72 -17
- package/src/persistence/embeddings/embedding-types.ts +0 -19
- package/src/persistence/jobs-store.ts +229 -0
- package/src/persistence/migrations/383-normalize-opencode-host-connections.ts +44 -0
- package/src/persistence/migrations/__tests__/383-normalize-opencode-host-connections.test.ts +102 -0
- package/src/persistence/steps.ts +12 -0
- package/src/platform/sync-avatar.test.ts +29 -0
- package/src/plugin-api/model-profiles.ts +5 -3
- package/src/plugins/defaults/memory/AGENTS.md +5 -3
- package/src/plugins/defaults/memory/__tests__/memory-retrospective-skill-card.test.ts +2 -0
- package/src/plugins/defaults/memory/__tests__/skill-update-receipt-boundary-guard.test.ts +45 -0
- package/src/plugins/defaults/memory/__tests__/skill-update-receipt-job.test.ts +580 -0
- package/src/plugins/defaults/memory/graph/tools.ts +9 -5
- package/src/plugins/defaults/memory/hooks/conversation-deleted.ts +4 -0
- package/src/plugins/defaults/memory/job-handlers.ts +8 -2
- package/src/plugins/defaults/memory/memory-retrospective-skill-card.ts +5 -0
- package/src/plugins/defaults/memory/skill-update-receipt-job.ts +334 -0
- package/src/plugins/defaults/memory/substrate/skill-content.ts +1 -1
- package/src/plugins/defaults/memory/tools.test.ts +17 -0
- package/src/plugins/defaults/memory/v3/__tests__/render-injection.test.ts +3 -0
- package/src/providers/__tests__/connection-model-compat.test.ts +2 -0
- package/src/providers/__tests__/search-provider-catalog.test.ts +8 -0
- package/src/providers/anthropic/client.ts +2 -2
- package/src/providers/gemini/client.ts +17 -1
- package/src/providers/inference/auth.ts +25 -0
- package/src/providers/jev/ask.test.ts +66 -0
- package/src/{calls/typesafe-noul.ts → providers/jev/ask.ts} +75 -36
- package/src/providers/malformed-tool-call.ts +55 -0
- package/src/providers/model-catalog.ts +257 -4
- package/src/providers/openai/__tests__/chat-completions-malformed-tool-call.test.ts +105 -0
- package/src/providers/openai/chat-completions-provider.ts +30 -0
- package/src/providers/openai/codex-models.ts +2 -0
- package/src/providers/platform-proxy/constants.ts +5 -0
- package/src/providers/retry.ts +68 -36
- package/src/providers/search-provider-catalog.ts +10 -0
- package/src/providers/vellum-model-routing.test.ts +1 -0
- package/src/runtime/__tests__/agent-wake-archived-gate.test.ts +90 -0
- package/src/runtime/__tests__/question-resolution.test.ts +32 -0
- package/src/runtime/agent-wake.ts +9 -2
- package/src/runtime/auth/__tests__/middleware.test.ts +138 -0
- package/src/runtime/auth/middleware.ts +15 -11
- package/src/runtime/middleware/auth.ts +7 -1
- package/src/runtime/pending-interactions.ts +8 -8
- package/src/runtime/question-resolution.ts +54 -8
- package/src/runtime/routes/__tests__/conversation-list-routes.test.ts +146 -0
- package/src/runtime/routes/__tests__/dictation-processing.test.ts +174 -0
- package/src/runtime/routes/__tests__/global-search-routes.test.ts +74 -1
- package/src/runtime/routes/__tests__/inference-provider-connection-routes.test.ts +60 -0
- package/src/runtime/routes/__tests__/llm-call-sites-routes.test.ts +16 -1
- package/src/runtime/routes/__tests__/plugins-routes.test.ts +8 -0
- package/src/runtime/routes/conversation-list-routes.ts +16 -14
- package/src/runtime/routes/conversation-query-routes.ts +4 -0
- package/src/runtime/routes/conversation-routes.ts +4 -2
- package/src/runtime/routes/diagnostics-routes.ts +66 -229
- package/src/runtime/routes/global-search-routes.ts +22 -7
- package/src/runtime/routes/inference-provider-connection-routes.ts +25 -8
- package/src/runtime/routes/llm-call-sites-routes.ts +19 -10
- package/src/runtime/routes/platform-routes.ts +6 -1
- package/src/runtime/routes/plugins-routes.ts +5 -2
- package/src/runtime/sync/documents-sidecar-publish.test.ts +50 -4
- package/src/runtime/sync/resource-sync-events.ts +11 -0
- package/src/runtime/sync/worker-daemon-notify.test.ts +30 -0
- package/src/runtime/sync/worker-daemon-notify.ts +44 -0
- package/src/runtime/verification-outbound-actions.ts +2 -2
- package/src/skills/validate-input.ts +75 -11
- package/src/telegram/__tests__/webhook-health.test.ts +8 -0
- package/src/tools/ask-question/ask-question-tool.test.ts +38 -0
- package/src/tools/ask-question/ask-question-tool.ts +12 -1
- package/src/tools/network/__tests__/web-fetch-firecrawl.test.ts +28 -1
- package/src/tools/network/__tests__/web-fetch-metadata.test.ts +34 -0
- package/src/tools/network/__tests__/web-fetch-tinyfish.test.ts +35 -1
- package/src/tools/network/__tests__/web-search.test.ts +96 -0
- package/src/tools/network/web-fetch.ts +30 -9
- package/src/tools/network/web-search.ts +235 -4
- package/src/tools/skills/execute.ts +1 -1
- package/src/tools/skills/format-input-schema.ts +15 -0
- package/src/tools/skills/load.ts +15 -43
- package/src/tools/skills/scaffold-managed.ts +86 -71
- package/src/tools/skills/skill-tool-factory.ts +2 -1
- package/src/tools/workflows/manage-workflows.ts +4 -2
- package/src/util/worker-pipe.test.ts +146 -0
- package/src/util/worker-pipe.ts +49 -0
- package/src/workspace/migrations/158-repoint-opencode-host-provider-fragments.ts +163 -0
- package/src/workspace/migrations/159-rename-colliding-jev-profile-name.ts +344 -0
- package/src/workspace/migrations/__tests__/158-repoint-opencode-host-provider-fragments.test.ts +188 -0
- package/src/workspace/migrations/registry.ts +4 -0
- package/src/runtime/routes/__tests__/dictation-cleanup-guard.test.ts +0 -107
|
@@ -321,6 +321,11 @@ async function insertOrDeferSkillCard(
|
|
|
321
321
|
metadata: { kind: SKILL_CARD_MESSAGE_KIND, automated: true },
|
|
322
322
|
skipIndexing: true,
|
|
323
323
|
clientMessageId: surfaceId,
|
|
324
|
+
// The card is what the pass learned about this conversation, not new
|
|
325
|
+
// activity in it, and a pass runs once the conversation has gone idle.
|
|
326
|
+
// That is exactly when the user has just marked it done, so a card that
|
|
327
|
+
// resurfaced would bounce fresh done chats back into the sidebar.
|
|
328
|
+
skipResurface: true,
|
|
324
329
|
},
|
|
325
330
|
);
|
|
326
331
|
if (persisted.deduplicated) {
|
|
@@ -0,0 +1,334 @@
|
|
|
1
|
+
// ---------------------------------------------------------------------------
|
|
2
|
+
// Memory retrospective: skill-update receipts.
|
|
3
|
+
// ---------------------------------------------------------------------------
|
|
4
|
+
//
|
|
5
|
+
// A background pass can rewrite several managed skills within minutes. A
|
|
6
|
+
// receipt collapses one such burst into one announcement: every rewrite is
|
|
7
|
+
// appended to the one pending `skill_update_receipt` job
|
|
8
|
+
// (`upsertSkillUpdateReceiptJob`), and this handler announces the burst once
|
|
9
|
+
// it has settled, through `emitNotificationSignal`, the single entry point
|
|
10
|
+
// every notification producer uses. The same shape as the skill card's
|
|
11
|
+
// delivery job, keyed to a burst rather than to a run.
|
|
12
|
+
//
|
|
13
|
+
// Settled means every run that contributed an entry has finished (its fork
|
|
14
|
+
// conversation is no longer processing, or is gone) and no entry has landed
|
|
15
|
+
// for the quiet window. A burst that never goes quiet is announced at the
|
|
16
|
+
// cap, measured from its earliest entry. Until then the handler re-upserts
|
|
17
|
+
// the payload for a later check, the way the card defers until its run ends,
|
|
18
|
+
// so no attempt counter accumulates.
|
|
19
|
+
//
|
|
20
|
+
// The pending-only match in the upsert is what makes a burst boundary
|
|
21
|
+
// possible without a table of its own, and also what bounds it. The claimed
|
|
22
|
+
// row's payload is frozen, so an entry landing during evaluation opens a
|
|
23
|
+
// pending sibling; the handler checks for one right before announcing and,
|
|
24
|
+
// if it finds one, merges its own entries into it and announces nothing,
|
|
25
|
+
// leaving the sibling to carry the whole burst. Two windows stay open, and
|
|
26
|
+
// both surface as a second bell row rather than a lost entry: an entry
|
|
27
|
+
// landing between that check and the announcement's event row, and a
|
|
28
|
+
// sibling claimed by another worker between the check and the merge, in
|
|
29
|
+
// which case the merge opens a third row. Both are accepted.
|
|
30
|
+
//
|
|
31
|
+
// One announcement per row: the dedupe key is the row id, so a retry after a
|
|
32
|
+
// crash past the event row meets the key and completes. A pipeline failure
|
|
33
|
+
// before that is reported as retryable and the worker's own budget covers
|
|
34
|
+
// it; a row that exhausts it loses its entries, as the per-update notice it
|
|
35
|
+
// replaces would have.
|
|
36
|
+
|
|
37
|
+
import {
|
|
38
|
+
getConversation,
|
|
39
|
+
isConversationProcessing,
|
|
40
|
+
} from "@vellumai/plugin-api";
|
|
41
|
+
|
|
42
|
+
import { emitNotificationSignal } from "../../../notifications/emit-signal.js";
|
|
43
|
+
import {
|
|
44
|
+
hasPendingJobOfType,
|
|
45
|
+
type MemoryJob,
|
|
46
|
+
parseSkillUpdateReceiptPayload,
|
|
47
|
+
type SkillUpdateReceiptEntry,
|
|
48
|
+
type SkillUpdateReceiptJobPayload,
|
|
49
|
+
upsertSkillUpdateReceiptJob,
|
|
50
|
+
} from "../../../persistence/jobs-store.js";
|
|
51
|
+
import type { JobQueueResolution } from "../../types.js";
|
|
52
|
+
import { getLogger } from "./logging.js";
|
|
53
|
+
|
|
54
|
+
const log = getLogger("skill-update-receipt-job");
|
|
55
|
+
|
|
56
|
+
/**
|
|
57
|
+
* How long a receipt stays open after its last entry before it is announced.
|
|
58
|
+
* Equal to the retrospective's default interval trigger, so runs a
|
|
59
|
+
* conversation's message count keeps triggering (at most every five minutes)
|
|
60
|
+
* chain into one receipt, while interval-driven runs of a slow conversation
|
|
61
|
+
* each get their own.
|
|
62
|
+
*/
|
|
63
|
+
export const SKILL_UPDATE_RECEIPT_QUIET_MS = 30 * 60 * 1000;
|
|
64
|
+
|
|
65
|
+
/**
|
|
66
|
+
* The longest a receipt stays open, measured from its earliest entry. A
|
|
67
|
+
* burst that never goes quiet is announced at the cap and a later entry
|
|
68
|
+
* opens the next receipt.
|
|
69
|
+
*/
|
|
70
|
+
export const SKILL_UPDATE_RECEIPT_CAP_MS = 2 * 60 * 60 * 1000;
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* How soon the handler looks again at a receipt whose contributing run is
|
|
74
|
+
* still live. The run's end is what it waits for, and a run is one wake, so a
|
|
75
|
+
* short cadence catches it without polling hard.
|
|
76
|
+
*/
|
|
77
|
+
export const SKILL_UPDATE_RECEIPT_LIVE_RUN_RECHECK_MS = 30_000;
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Record one rewrite on the open receipt and make sure a check is queued.
|
|
81
|
+
* Called by the scaffold tool after a background overwrite lands. Throws on
|
|
82
|
+
* a store failure; the caller logs it and lets the skill write stand.
|
|
83
|
+
*/
|
|
84
|
+
export function recordSkillUpdate(
|
|
85
|
+
entry: Omit<SkillUpdateReceiptEntry, "createdAt">,
|
|
86
|
+
now: number = Date.now(),
|
|
87
|
+
): void {
|
|
88
|
+
upsertSkillUpdateReceiptJob(
|
|
89
|
+
{
|
|
90
|
+
firstEntryAt: now,
|
|
91
|
+
lastEntryAt: now,
|
|
92
|
+
entries: [{ ...entry, createdAt: now }],
|
|
93
|
+
},
|
|
94
|
+
now + SKILL_UPDATE_RECEIPT_QUIET_MS,
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
/** The dedupe key a receipt row is announced under. */
|
|
99
|
+
export function skillUpdateReceiptDedupeKey(jobId: string): string {
|
|
100
|
+
return `skill-update-receipt:${jobId}`;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* The `sourceContextId` a receipt is announced with. The home feed's "Go to
|
|
105
|
+
* Conversation" and the vellum delivery's body append both resolve it to a
|
|
106
|
+
* conversation, so a receipt whose rewrites all came from one conversation
|
|
107
|
+
* names it; one spanning several, or with a rewrite of unknown lineage,
|
|
108
|
+
* passes a sentinel that resolves to nothing, and the receipt's own entries
|
|
109
|
+
* carry the links.
|
|
110
|
+
*/
|
|
111
|
+
export function skillUpdateReceiptSourceContextId(
|
|
112
|
+
jobId: string,
|
|
113
|
+
entries: SkillUpdateReceiptEntry[],
|
|
114
|
+
): string {
|
|
115
|
+
// An entry with no lineage counts as its own source: linking the whole
|
|
116
|
+
// receipt to the one known conversation would attribute that rewrite
|
|
117
|
+
// there too.
|
|
118
|
+
const sources = new Set(entries.map((entry) => entry.sourceConversationId));
|
|
119
|
+
const [only] = sources;
|
|
120
|
+
return sources.size === 1 && only ? only : `skill-update-receipt:${jobId}`;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/**
|
|
124
|
+
* The English fallback title and body, for a surface that renders the item
|
|
125
|
+
* as it arrived: a bundle without the receipt panel, a channel post, a
|
|
126
|
+
* banner. The web client derives its own title from the entries.
|
|
127
|
+
*/
|
|
128
|
+
export function composeSkillUpdateReceiptCopy(
|
|
129
|
+
entries: SkillUpdateReceiptEntry[],
|
|
130
|
+
): { title: string; body: string } {
|
|
131
|
+
const skills = new Map<string, string>();
|
|
132
|
+
for (const entry of entries) {
|
|
133
|
+
skills.set(entry.skillId, entry.name);
|
|
134
|
+
}
|
|
135
|
+
const names = [...skills.values()];
|
|
136
|
+
const title =
|
|
137
|
+
names.length === 1
|
|
138
|
+
? `Skill updated: ${names[0]}`
|
|
139
|
+
: `${names.length} skills updated`;
|
|
140
|
+
const body = entries
|
|
141
|
+
.map((entry) => `- ${entry.name}: ${entry.changeSummary}`)
|
|
142
|
+
.join("\n");
|
|
143
|
+
return { title, body };
|
|
144
|
+
}
|
|
145
|
+
|
|
146
|
+
export type SkillUpdateReceiptSealDecision =
|
|
147
|
+
| { seal: "quiet" | "cap" }
|
|
148
|
+
| { seal: null; nextCheckAt: number };
|
|
149
|
+
|
|
150
|
+
/**
|
|
151
|
+
* Whether the receipt is due to be announced, and if not, when to look
|
|
152
|
+
* again. Pure, so the boundary can be asserted without a clock or a queue.
|
|
153
|
+
*/
|
|
154
|
+
export function decideSkillUpdateReceiptSeal(args: {
|
|
155
|
+
receipt: Pick<SkillUpdateReceiptJobPayload, "firstEntryAt" | "lastEntryAt">;
|
|
156
|
+
anyRunLive: boolean;
|
|
157
|
+
now: number;
|
|
158
|
+
}): SkillUpdateReceiptSealDecision {
|
|
159
|
+
const capAt = args.receipt.firstEntryAt + SKILL_UPDATE_RECEIPT_CAP_MS;
|
|
160
|
+
if (args.now >= capAt) {
|
|
161
|
+
return { seal: "cap" };
|
|
162
|
+
}
|
|
163
|
+
const quietAt = args.receipt.lastEntryAt + SKILL_UPDATE_RECEIPT_QUIET_MS;
|
|
164
|
+
if (args.anyRunLive) {
|
|
165
|
+
return {
|
|
166
|
+
seal: null,
|
|
167
|
+
nextCheckAt: Math.min(
|
|
168
|
+
args.now + SKILL_UPDATE_RECEIPT_LIVE_RUN_RECHECK_MS,
|
|
169
|
+
capAt,
|
|
170
|
+
),
|
|
171
|
+
};
|
|
172
|
+
}
|
|
173
|
+
if (args.now >= quietAt) {
|
|
174
|
+
return { seal: "quiet" };
|
|
175
|
+
}
|
|
176
|
+
return { seal: null, nextCheckAt: Math.min(quietAt, capAt) };
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
/**
|
|
180
|
+
* Whether the run that produced an entry has finished. A fork conversation
|
|
181
|
+
* that no longer exists counts as finished: superseded-fork GC deletes the
|
|
182
|
+
* row, and a run cannot be processing without one. Same reading as the
|
|
183
|
+
* skill card's gate.
|
|
184
|
+
*/
|
|
185
|
+
async function isRunFinished(runConversationId: string): Promise<boolean> {
|
|
186
|
+
const conversation = await getConversation(runConversationId);
|
|
187
|
+
if (!conversation) {
|
|
188
|
+
return true;
|
|
189
|
+
}
|
|
190
|
+
return !(await isConversationProcessing(runConversationId));
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
/**
|
|
194
|
+
* The entries whose source conversation still exists. An entry with no
|
|
195
|
+
* lineage has nothing to check and stays.
|
|
196
|
+
*/
|
|
197
|
+
async function dropEntriesOfDeletedSources(
|
|
198
|
+
entries: SkillUpdateReceiptEntry[],
|
|
199
|
+
): Promise<SkillUpdateReceiptEntry[]> {
|
|
200
|
+
const sources = new Set(
|
|
201
|
+
entries.flatMap((entry) =>
|
|
202
|
+
entry.sourceConversationId ? [entry.sourceConversationId] : [],
|
|
203
|
+
),
|
|
204
|
+
);
|
|
205
|
+
const gone = new Set<string>();
|
|
206
|
+
for (const source of sources) {
|
|
207
|
+
if (!(await getConversation(source))) {
|
|
208
|
+
gone.add(source);
|
|
209
|
+
}
|
|
210
|
+
}
|
|
211
|
+
return entries.filter(
|
|
212
|
+
(entry) =>
|
|
213
|
+
!entry.sourceConversationId || !gone.has(entry.sourceConversationId),
|
|
214
|
+
);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
/**
|
|
218
|
+
* Job handler for `skill_update_receipt` (registered in `job-handlers.ts`).
|
|
219
|
+
* A malformed payload is dropped with a warning. A pipeline failure returns
|
|
220
|
+
* `retryable` so the worker's budget covers it.
|
|
221
|
+
*/
|
|
222
|
+
export async function skillUpdateReceiptJob(
|
|
223
|
+
job: MemoryJob,
|
|
224
|
+
): Promise<JobQueueResolution | undefined> {
|
|
225
|
+
const payload = parseSkillUpdateReceiptPayload(job.payload);
|
|
226
|
+
if (!payload || payload.entries.length === 0) {
|
|
227
|
+
log.warn(
|
|
228
|
+
{ jobId: job.id },
|
|
229
|
+
"skill-update receipt: dropping job with malformed or empty payload",
|
|
230
|
+
);
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
|
|
234
|
+
const runIds = [
|
|
235
|
+
...new Set(payload.entries.map((entry) => entry.runConversationId)),
|
|
236
|
+
];
|
|
237
|
+
let anyRunLive = false;
|
|
238
|
+
for (const runId of runIds) {
|
|
239
|
+
if (!(await isRunFinished(runId))) {
|
|
240
|
+
anyRunLive = true;
|
|
241
|
+
break;
|
|
242
|
+
}
|
|
243
|
+
}
|
|
244
|
+
const decision = decideSkillUpdateReceiptSeal({
|
|
245
|
+
receipt: payload,
|
|
246
|
+
anyRunLive,
|
|
247
|
+
now: Date.now(),
|
|
248
|
+
});
|
|
249
|
+
if (decision.seal === null) {
|
|
250
|
+
// Not settled. The claimed row completes and the payload moves to a
|
|
251
|
+
// pending row for the next check, merging with any entries that landed
|
|
252
|
+
// meanwhile.
|
|
253
|
+
upsertSkillUpdateReceiptJob(payload, decision.nextCheckAt);
|
|
254
|
+
return undefined;
|
|
255
|
+
}
|
|
256
|
+
|
|
257
|
+
if (hasPendingJobOfType("skill_update_receipt")) {
|
|
258
|
+
// An entry landed while this row was being evaluated and opened a
|
|
259
|
+
// sibling. The sibling carries the burst: merge into it and announce
|
|
260
|
+
// nothing, so the two rows do not become two bell rows.
|
|
261
|
+
upsertSkillUpdateReceiptJob(payload, Date.now());
|
|
262
|
+
log.info(
|
|
263
|
+
{ jobId: job.id, entryCount: payload.entries.length },
|
|
264
|
+
"skill-update receipt: merged into the sibling that opened during evaluation",
|
|
265
|
+
);
|
|
266
|
+
return undefined;
|
|
267
|
+
}
|
|
268
|
+
|
|
269
|
+
// The claimed payload is a snapshot: a source conversation deleted while
|
|
270
|
+
// this row was evaluated was purged from pending rows only. Announce
|
|
271
|
+
// nothing distilled from a conversation that is gone.
|
|
272
|
+
const entries = await dropEntriesOfDeletedSources(payload.entries);
|
|
273
|
+
if (entries.length === 0) {
|
|
274
|
+
log.info(
|
|
275
|
+
{ jobId: job.id },
|
|
276
|
+
"skill-update receipt: every entry's source conversation was deleted; nothing to announce",
|
|
277
|
+
);
|
|
278
|
+
return undefined;
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
const { title, body } = composeSkillUpdateReceiptCopy(entries);
|
|
282
|
+
const skillIds = new Set(entries.map((entry) => entry.skillId));
|
|
283
|
+
const [onlySkillId] = skillIds;
|
|
284
|
+
const result = await emitNotificationSignal({
|
|
285
|
+
// A tool's work reported after the fact, not the scheduler's:
|
|
286
|
+
// `home-feed-side-effect` derives `fromAssistant` from the channel.
|
|
287
|
+
sourceChannel: "assistant_tool",
|
|
288
|
+
sourceContextId: skillUpdateReceiptSourceContextId(job.id, entries),
|
|
289
|
+
sourceEventName: "activity.complete",
|
|
290
|
+
dedupeKey: skillUpdateReceiptDedupeKey(job.id),
|
|
291
|
+
contextPayload: {
|
|
292
|
+
summary: body,
|
|
293
|
+
title,
|
|
294
|
+
body,
|
|
295
|
+
// The typed list the receipt panel renders; the feed maps it onto the
|
|
296
|
+
// item and strips it from the free-form metadata.
|
|
297
|
+
updates: entries.map((entry) => ({
|
|
298
|
+
skillId: entry.skillId,
|
|
299
|
+
name: entry.name,
|
|
300
|
+
summary: entry.changeSummary,
|
|
301
|
+
...(entry.sourceConversationId
|
|
302
|
+
? { conversationId: entry.sourceConversationId }
|
|
303
|
+
: {}),
|
|
304
|
+
})),
|
|
305
|
+
// A receipt naming one skill links to it from the detail's footer the
|
|
306
|
+
// way a single-skill notification does.
|
|
307
|
+
...(skillIds.size === 1 && onlySkillId ? { skillId: onlySkillId } : {}),
|
|
308
|
+
},
|
|
309
|
+
attentionHints: {
|
|
310
|
+
requiresAction: false,
|
|
311
|
+
urgency: "low",
|
|
312
|
+
isAsyncBackground: true,
|
|
313
|
+
visibleInSourceNow: false,
|
|
314
|
+
},
|
|
315
|
+
});
|
|
316
|
+
if (result.pipelineFailed) {
|
|
317
|
+
return {
|
|
318
|
+
queueResolution: "retryable",
|
|
319
|
+
errorMessage: `skill-update receipt announcement failed: ${result.reason}`,
|
|
320
|
+
};
|
|
321
|
+
}
|
|
322
|
+
log.info(
|
|
323
|
+
{
|
|
324
|
+
jobId: job.id,
|
|
325
|
+
sealedBy: decision.seal,
|
|
326
|
+
entryCount: entries.length,
|
|
327
|
+
skillCount: skillIds.size,
|
|
328
|
+
deduplicated: result.deduplicated,
|
|
329
|
+
dispatched: result.dispatched,
|
|
330
|
+
},
|
|
331
|
+
"skill-update receipt announced",
|
|
332
|
+
);
|
|
333
|
+
return undefined;
|
|
334
|
+
}
|
|
@@ -34,7 +34,7 @@ export const DEFAULT_CARD_CHARS = 500;
|
|
|
34
34
|
* only, so a missing product is not evidence it is unsupported.
|
|
35
35
|
*/
|
|
36
36
|
export const SKILLS_INJECTION_CATALOG_HINT =
|
|
37
|
-
"Injected skills are only ones currently in the workspace. Run `assistant plugins search <name>` and `assistant skills search <name>` before concluding a given integration or skill is unsupported";
|
|
37
|
+
"These skill cards describe availability, not tool instructions. When skill_load is available, use it to load a skill's current instructions and input schemas before calling its tools; follow those schemas over remembered arguments. Injected skills are only ones currently in the workspace. Run `assistant plugins search <name>` and `assistant skills search <name>` before concluding a given integration or skill is unsupported";
|
|
38
38
|
|
|
39
39
|
/**
|
|
40
40
|
* Render the prose-style capability statement embedded into the unified
|
|
@@ -451,6 +451,23 @@ describe("rememberTool definition", () => {
|
|
|
451
451
|
finish_turn: { type: "boolean" },
|
|
452
452
|
});
|
|
453
453
|
});
|
|
454
|
+
|
|
455
|
+
test("says later capture is conditional and never covers scheduled work", () => {
|
|
456
|
+
const description = rememberTool.description;
|
|
457
|
+
expect(description).toContain("not guaranteed");
|
|
458
|
+
expect(description).toContain("never runs over scheduled work");
|
|
459
|
+
expect(description).toContain("save it now");
|
|
460
|
+
// A pass that reviews every conversation on a cadence is not what runs:
|
|
461
|
+
// the enqueue gate skips scheduled conversations.
|
|
462
|
+
expect(description).not.toContain("after each message-count");
|
|
463
|
+
expect(description).not.toContain("saves what you didn't capture");
|
|
464
|
+
// The provider reads the definition after JSON serialization, so the
|
|
465
|
+
// contract has to survive that round trip unchanged.
|
|
466
|
+
const serialized = z
|
|
467
|
+
.object({ description: z.string() })
|
|
468
|
+
.parse(JSON.parse(JSON.stringify(rememberTool)));
|
|
469
|
+
expect(serialized.description).toBe(description);
|
|
470
|
+
});
|
|
454
471
|
});
|
|
455
472
|
|
|
456
473
|
describe("rememberTool definition — page-hint guidance gating", () => {
|
|
@@ -31,6 +31,9 @@ describe("renderInjectionBlockInner", () => {
|
|
|
31
31
|
expect(inner).toContain("assistant plugins search <name>");
|
|
32
32
|
expect(inner).toContain("assistant skills search <name>");
|
|
33
33
|
expect(inner).toContain("currently in the workspace");
|
|
34
|
+
expect(inner).toContain("When skill_load is available, use it");
|
|
35
|
+
expect(inner).toContain("before calling its tools");
|
|
36
|
+
expect(inner).toContain("over remembered arguments");
|
|
34
37
|
expect(inner.indexOf("# Skills")).toBeLessThan(inner.indexOf("# Skill:"));
|
|
35
38
|
expect(inner.startsWith(`${V3_INJECTION_HEADER}\n\n# Skills\n`)).toBe(true);
|
|
36
39
|
});
|
|
@@ -57,6 +57,8 @@ describe("isConnectionCompatibleWithModel", () => {
|
|
|
57
57
|
test("oauth_subscription connection is compatible with a Codex model", () => {
|
|
58
58
|
const conn = { auth: oauthAuth };
|
|
59
59
|
expect(isConnectionCompatibleWithModel(conn, "gpt-6-astra")).toBe(true);
|
|
60
|
+
expect(isConnectionCompatibleWithModel(conn, "gpt-6-sol")).toBe(true);
|
|
61
|
+
expect(isConnectionCompatibleWithModel(conn, "gpt-6-luna")).toBe(true);
|
|
60
62
|
expect(isConnectionCompatibleWithModel(conn, "gpt-5.6-sol")).toBe(true);
|
|
61
63
|
expect(isConnectionCompatibleWithModel(conn, "gpt-5.6-terra")).toBe(true);
|
|
62
64
|
expect(isConnectionCompatibleWithModel(conn, "gpt-5.6-luna")).toBe(true);
|
|
@@ -104,4 +104,12 @@ describe("search-provider-catalog", () => {
|
|
|
104
104
|
expect(tinyfish?.defaultApiBase).toBe("https://api.search.tinyfish.ai");
|
|
105
105
|
expect(tinyfish?.envVar).toBe("TINYFISH_API_KEY");
|
|
106
106
|
});
|
|
107
|
+
|
|
108
|
+
test("Exa is a keyed BYOK provider without a custom API base", () => {
|
|
109
|
+
const exa = getSearchProvider("exa");
|
|
110
|
+
expect(exa?.kind).toBe("byok");
|
|
111
|
+
expect(exa?.keyless).toBeUndefined();
|
|
112
|
+
expect(exa?.supportsApiBase).toBeUndefined();
|
|
113
|
+
expect(exa?.envVar).toBe("EXA_API_KEY");
|
|
114
|
+
});
|
|
107
115
|
});
|
|
@@ -1004,7 +1004,7 @@ export class AnthropicProvider implements Provider {
|
|
|
1004
1004
|
const isHaiku = effectiveModel.includes("haiku");
|
|
1005
1005
|
// Effort support is per-model: Haiku and Sonnet 4.5 reject the param (see isEffortSupported).
|
|
1006
1006
|
const supportsEffort = isEffortSupported(effectiveModel);
|
|
1007
|
-
// opus-4-7 / opus-4-8 / opus-5 and sonnet-5 reject `temperature`,
|
|
1007
|
+
// opus-4-7 / opus-4-8 / opus-5 / opus-5-5 and sonnet-5 reject `temperature`,
|
|
1008
1008
|
// `top_p`, and `top_k` with a 400 "`temperature`/`top_p` is deprecated
|
|
1009
1009
|
// for this model" — model-wide, not effort-conditional (verified
|
|
1010
1010
|
// 2026-06-23). opus-4-6 / sonnet-4-6 / haiku-4-5 still accept them.
|
|
@@ -1016,7 +1016,7 @@ export class AnthropicProvider implements Provider {
|
|
|
1016
1016
|
// suffix is what matches.
|
|
1017
1017
|
const deprecatesSamplingParams =
|
|
1018
1018
|
/claude-opus-4-[78]\b/.test(effectiveModel) ||
|
|
1019
|
-
/claude-opus-5
|
|
1019
|
+
/claude-opus-5(-5)?\b/.test(effectiveModel) ||
|
|
1020
1020
|
/claude-sonnet-5\b/.test(effectiveModel) ||
|
|
1021
1021
|
/claude-fable-/.test(effectiveModel);
|
|
1022
1022
|
const mergedOutputConfig = {
|
|
@@ -16,6 +16,10 @@ import {
|
|
|
16
16
|
} from "../content-block-size.js";
|
|
17
17
|
import { fileBlockToProviderText } from "../file-block-text.js";
|
|
18
18
|
import { unsignedThoughtSignatureFallback } from "../gemini-thought-signature.js";
|
|
19
|
+
import {
|
|
20
|
+
isMalformedToolCallFinishReason,
|
|
21
|
+
malformedToolCallError,
|
|
22
|
+
} from "../malformed-tool-call.js";
|
|
19
23
|
import { base64Source, resolveMediaReferences } from "../media-resolve.js";
|
|
20
24
|
import { PROVIDER_CATALOG } from "../model-catalog.js";
|
|
21
25
|
import { recordProviderRequestDiagnostics } from "../request-diagnostics.js";
|
|
@@ -577,6 +581,18 @@ export class GeminiProvider implements Provider {
|
|
|
577
581
|
cleanupTimeout();
|
|
578
582
|
}
|
|
579
583
|
|
|
584
|
+
if (isMalformedToolCallFinishReason(finishReason)) {
|
|
585
|
+
log.warn(
|
|
586
|
+
{
|
|
587
|
+
model: responseModel,
|
|
588
|
+
finishReason,
|
|
589
|
+
streamedTextLength: fullText.length,
|
|
590
|
+
},
|
|
591
|
+
"Gemini response ended on a malformed tool call",
|
|
592
|
+
);
|
|
593
|
+
throw malformedToolCallError("gemini", finishReason);
|
|
594
|
+
}
|
|
595
|
+
|
|
580
596
|
// Build content blocks
|
|
581
597
|
const content: ContentBlock[] = [];
|
|
582
598
|
if (fullText) {
|
|
@@ -649,7 +665,7 @@ export class GeminiProvider implements Provider {
|
|
|
649
665
|
error.status,
|
|
650
666
|
// Skip reason on caller-abort: abortReason already carries the intent
|
|
651
667
|
// and short-circuits classification/retry (mirrors the Anthropic client).
|
|
652
|
-
|
|
668
|
+
abortReason
|
|
653
669
|
? { abortReason, rawRequest: inspectableRequest }
|
|
654
670
|
: {
|
|
655
671
|
reason: deriveGeminiReason(error),
|
|
@@ -176,6 +176,31 @@ export type ConnectionModel = z.infer<typeof ConnectionModelSchema>;
|
|
|
176
176
|
* `models` list (openai-compatible endpoints have no fixed upstream, so the
|
|
177
177
|
* user must supply both).
|
|
178
178
|
*/
|
|
179
|
+
/**
|
|
180
|
+
* An openai-compatible connection whose endpoint is opencode.ai is an
|
|
181
|
+
* OpenCode connection: zen/go rejects requests without the session headers
|
|
182
|
+
* only the opencode adapter and probe send, so the generic adapter can never
|
|
183
|
+
* dispatch against it. Applied on create so the row is stored as provider
|
|
184
|
+
* "opencode" and every provider-keyed path treats it as one.
|
|
185
|
+
*/
|
|
186
|
+
export function normalizeConnectionProvider(
|
|
187
|
+
provider: string,
|
|
188
|
+
baseUrl: unknown,
|
|
189
|
+
): string {
|
|
190
|
+
if (provider !== "openai-compatible" || typeof baseUrl !== "string") {
|
|
191
|
+
return provider;
|
|
192
|
+
}
|
|
193
|
+
let host: string;
|
|
194
|
+
try {
|
|
195
|
+
host = new URL(baseUrl).hostname.toLowerCase();
|
|
196
|
+
} catch {
|
|
197
|
+
return provider;
|
|
198
|
+
}
|
|
199
|
+
return host === "opencode.ai" || host.endsWith(".opencode.ai")
|
|
200
|
+
? "opencode"
|
|
201
|
+
: provider;
|
|
202
|
+
}
|
|
203
|
+
|
|
179
204
|
export const PROVIDERS_REQUIRING_BASE_URL_AND_MODELS: ReadonlySet<string> =
|
|
180
205
|
new Set(["openai-compatible"]);
|
|
181
206
|
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { afterAll, describe, expect, mock, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
import type { Provider } from "../types.js";
|
|
4
|
+
|
|
5
|
+
let configured: {
|
|
6
|
+
provider: Provider;
|
|
7
|
+
configuredProviderName: string;
|
|
8
|
+
entryRouted: boolean;
|
|
9
|
+
} | null = null;
|
|
10
|
+
|
|
11
|
+
const actual = await import("../provider-send-message.js");
|
|
12
|
+
mock.module("../provider-send-message.js", () => ({
|
|
13
|
+
...actual,
|
|
14
|
+
resolveConfiguredProvider: async () => configured,
|
|
15
|
+
}));
|
|
16
|
+
|
|
17
|
+
const { resolveTypesafeProvider } = await import("./ask.js");
|
|
18
|
+
|
|
19
|
+
afterAll(() => {
|
|
20
|
+
mock.restore();
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
function stub(name: string): Provider {
|
|
24
|
+
return {
|
|
25
|
+
name,
|
|
26
|
+
defaultModel: "jev-latest",
|
|
27
|
+
sendMessage: async () => {
|
|
28
|
+
throw new Error("not dispatched in this test");
|
|
29
|
+
},
|
|
30
|
+
} as unknown as Provider;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
describe("resolveTypesafeProvider", () => {
|
|
34
|
+
test("a BYOK TypeSafe profile resolves", async () => {
|
|
35
|
+
configured = {
|
|
36
|
+
provider: stub("typesafe"),
|
|
37
|
+
configuredProviderName: "typesafe",
|
|
38
|
+
entryRouted: false,
|
|
39
|
+
};
|
|
40
|
+
expect((await resolveTypesafeProvider("voiceEscalationJudge"))?.name).toBe(
|
|
41
|
+
"typesafe",
|
|
42
|
+
);
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
test("a managed Jev profile resolves although it is configured as vellum", async () => {
|
|
46
|
+
configured = {
|
|
47
|
+
provider: stub("typesafe"),
|
|
48
|
+
configuredProviderName: "vellum",
|
|
49
|
+
entryRouted: false,
|
|
50
|
+
};
|
|
51
|
+
expect((await resolveTypesafeProvider("voiceEscalationJudge"))?.name).toBe(
|
|
52
|
+
"typesafe",
|
|
53
|
+
);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
test("any other dispatched provider is unavailable", async () => {
|
|
57
|
+
configured = {
|
|
58
|
+
provider: stub("anthropic"),
|
|
59
|
+
configuredProviderName: "vellum",
|
|
60
|
+
entryRouted: false,
|
|
61
|
+
};
|
|
62
|
+
expect(await resolveTypesafeProvider("voiceEscalationJudge")).toBeNull();
|
|
63
|
+
configured = null;
|
|
64
|
+
expect(await resolveTypesafeProvider("voiceEscalationJudge")).toBeNull();
|
|
65
|
+
});
|
|
66
|
+
});
|