@oxygen-agent/cli 1.1013.93 → 1.1014.18
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/README.md +1 -1
- package/dist/index.js +190 -70
- package/node_modules/@oxygen/shared/dist/agent-sessions.d.ts +115 -0
- package/node_modules/@oxygen/shared/dist/agent-sessions.js +133 -0
- package/node_modules/@oxygen/shared/dist/app-storage-maintenance.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/app-storage-maintenance.js +16 -0
- package/node_modules/@oxygen/shared/dist/capability-discovery.js +77 -4
- package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.d.ts +72 -0
- package/node_modules/@oxygen/shared/dist/contracts/agent-sessions.js +35 -0
- package/node_modules/@oxygen/shared/dist/contracts/browser-operations.js +1 -0
- package/node_modules/@oxygen/shared/dist/contracts/operations.js +4 -3
- package/node_modules/@oxygen/shared/dist/contracts/table-query-grid.d.ts +3 -0
- package/node_modules/@oxygen/shared/dist/contracts/table-query-grid.js +16 -4
- package/node_modules/@oxygen/shared/dist/copilot-access.d.ts +33 -0
- package/node_modules/@oxygen/shared/dist/copilot-access.js +33 -0
- package/node_modules/@oxygen/shared/dist/copilot-questions.d.ts +91 -0
- package/node_modules/@oxygen/shared/dist/copilot-questions.js +246 -0
- package/node_modules/@oxygen/shared/dist/hosted-ai.d.ts +28 -18
- package/node_modules/@oxygen/shared/dist/hosted-ai.js +96 -45
- package/node_modules/@oxygen/shared/dist/langfuse.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/langfuse.js +37 -9
- package/node_modules/@oxygen/shared/dist/object-storage.js +9 -0
- package/node_modules/@oxygen/shared/dist/sequences.d.ts +5 -1
- package/node_modules/@oxygen/shared/dist/sequences.js +91 -0
- package/node_modules/@oxygen/shared/dist/tool-call-markup.d.ts +23 -0
- package/node_modules/@oxygen/shared/dist/tool-call-markup.js +213 -0
- package/node_modules/@oxygen/shared/dist/user-capability-routing.js +5 -0
- package/node_modules/@oxygen/shared/dist/version.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.d.ts +1 -1
- package/node_modules/@oxygen/shared/dist/version.generated.js +1 -1
- package/node_modules/@oxygen/shared/dist/version.js +1 -1
- package/node_modules/@oxygen/shared/dist/workspace-file-storage.js +4 -0
- package/package.json +1 -1
|
@@ -51,7 +51,7 @@ const LANGFUSE_EXPORT_TIMEOUT_SECONDS = 10;
|
|
|
51
51
|
const MAX_JSON_FIELD_CHARS = 400_000;
|
|
52
52
|
/**
|
|
53
53
|
* FAIL CLOSED: LLM tracing is on only when OXYGEN_LLM_TRACING_ENABLED is exactly
|
|
54
|
-
* "1"/"true" (trimmed, case-insensitive)
|
|
54
|
+
* "1"/"true" (trimmed, case-insensitive), both keys and an explicit self-hosted URL.
|
|
55
55
|
*/
|
|
56
56
|
export function isLlmTracingEnabled(env = process.env) {
|
|
57
57
|
const raw = env.OXYGEN_LLM_TRACING_ENABLED;
|
|
@@ -61,7 +61,22 @@ export function isLlmTracingEnabled(env = process.env) {
|
|
|
61
61
|
if (normalized !== "1" && normalized !== "true")
|
|
62
62
|
return false;
|
|
63
63
|
return ((env.LANGFUSE_PUBLIC_KEY?.trim() ?? "") !== "" &&
|
|
64
|
-
(env.LANGFUSE_SECRET_KEY?.trim() ?? "") !== ""
|
|
64
|
+
(env.LANGFUSE_SECRET_KEY?.trim() ?? "") !== "" &&
|
|
65
|
+
langfuseEndpoint(env) !== undefined);
|
|
66
|
+
}
|
|
67
|
+
// Never let the SDK's managed default receive protected prompt payloads.
|
|
68
|
+
function langfuseEndpoint(env) {
|
|
69
|
+
try {
|
|
70
|
+
const url = new URL(env.LANGFUSE_BASE_URL?.trim() ?? "");
|
|
71
|
+
const hostname = url.hostname.replace(/\.$/, "");
|
|
72
|
+
if (!["https:", "http:"].includes(url.protocol) || url.username || url.password || url.search || url.hash ||
|
|
73
|
+
hostname === "langfuse.com" || hostname.endsWith(".langfuse.com"))
|
|
74
|
+
return undefined;
|
|
75
|
+
return url.toString().replace(/\/+$/, "");
|
|
76
|
+
}
|
|
77
|
+
catch {
|
|
78
|
+
return undefined;
|
|
79
|
+
}
|
|
65
80
|
}
|
|
66
81
|
// The Langfuse "environment" dimension separates dev/prod traces inside one
|
|
67
82
|
// project. Explicit LANGFUSE_TRACING_ENVIRONMENT (Doppler, per config) wins.
|
|
@@ -234,7 +249,10 @@ function createOtelEmitter(env, warn, summary) {
|
|
|
234
249
|
import("@langfuse/tracing"), import("@opentelemetry/api"), import("@langfuse/core"),
|
|
235
250
|
import("@opentelemetry/otlp-transformer"),
|
|
236
251
|
]);
|
|
237
|
-
const
|
|
252
|
+
const endpoint = langfuseEndpoint(env);
|
|
253
|
+
if (!endpoint)
|
|
254
|
+
throw new Error("langfuse_endpoint_invalid");
|
|
255
|
+
const exportUrl = `${endpoint}/api/public/otel/v1/traces`;
|
|
238
256
|
const exportHeaders = {
|
|
239
257
|
"Content-Type": "application/json",
|
|
240
258
|
Authorization: `Basic ${Buffer.from(`${env.LANGFUSE_PUBLIC_KEY}:${env.LANGFUSE_SECRET_KEY}`).toString("base64")}`,
|
|
@@ -473,9 +491,7 @@ function createOtelEmitter(env, warn, summary) {
|
|
|
473
491
|
* environment tag; the tag is the `environment` FIELD on the score body, and it
|
|
474
492
|
* is resolved from the same helper the span processor uses so a score lands in
|
|
475
493
|
* the same Langfuse environment as the trace it scores. OXYGEN always sets
|
|
476
|
-
* LANGFUSE_BASE_URL
|
|
477
|
-
* the fallback is the SDK's documented default, which `@langfuse/core` itself
|
|
478
|
-
* does not supply.
|
|
494
|
+
* LANGFUSE_BASE_URL explicitly; no managed endpoint is a fallback.
|
|
479
495
|
*/
|
|
480
496
|
function createApiScorer(env, warn) {
|
|
481
497
|
let handle = null;
|
|
@@ -483,8 +499,11 @@ function createApiScorer(env, warn) {
|
|
|
483
499
|
handle ??= (async () => {
|
|
484
500
|
try {
|
|
485
501
|
const { LangfuseAPIClient } = await import("@langfuse/core");
|
|
502
|
+
const endpoint = langfuseEndpoint(env);
|
|
503
|
+
if (!endpoint)
|
|
504
|
+
throw new Error("langfuse_endpoint_invalid");
|
|
486
505
|
const client = new LangfuseAPIClient({
|
|
487
|
-
environment:
|
|
506
|
+
environment: endpoint,
|
|
488
507
|
username: env.LANGFUSE_PUBLIC_KEY,
|
|
489
508
|
password: env.LANGFUSE_SECRET_KEY,
|
|
490
509
|
});
|
|
@@ -570,7 +589,11 @@ function mayPublishMissingPrompts(env, baseUrl) {
|
|
|
570
589
|
return host !== "langfuse.com" && !host.endsWith(".langfuse.com");
|
|
571
590
|
}
|
|
572
591
|
export function createApiPromptResolver(env, warn, fetchImpl = fetch, now = Date.now) {
|
|
573
|
-
const baseUrl = (env
|
|
592
|
+
const baseUrl = langfuseEndpoint(env);
|
|
593
|
+
if (!baseUrl) {
|
|
594
|
+
warn(new Error("langfuse_endpoint_invalid"), { stage: "prompt_link" });
|
|
595
|
+
return { versionFor: () => undefined };
|
|
596
|
+
}
|
|
574
597
|
const authorization = `Basic ${Buffer.from(`${env.LANGFUSE_PUBLIC_KEY}:${env.LANGFUSE_SECRET_KEY}`).toString("base64")}`;
|
|
575
598
|
const cache = new Map();
|
|
576
599
|
const inflight = new Set();
|
|
@@ -663,8 +686,13 @@ export function createApiPromptResolver(env, warn, fetchImpl = fetch, now = Date
|
|
|
663
686
|
* this direct factory exists for tests (inject `emitterImpl`).
|
|
664
687
|
*/
|
|
665
688
|
export function createLlmTracingClient(env = process.env, options) {
|
|
666
|
-
if (!options?.emitterImpl && !isLlmTracingEnabled(env))
|
|
689
|
+
if (!options?.emitterImpl && !isLlmTracingEnabled(env)) {
|
|
690
|
+
if (["1", "true"].includes(env.OXYGEN_LLM_TRACING_ENABLED?.trim().toLowerCase() ?? "") &&
|
|
691
|
+
env.LANGFUSE_PUBLIC_KEY?.trim() && env.LANGFUSE_SECRET_KEY?.trim() && !langfuseEndpoint(env)) {
|
|
692
|
+
log("warn", "llm_tracing.ingest_failed", { provider: "langfuse", error_code: "langfuse_endpoint_invalid", stage: "configuration" });
|
|
693
|
+
}
|
|
667
694
|
return null;
|
|
695
|
+
}
|
|
668
696
|
const lastWarnAtMs = new Map();
|
|
669
697
|
const warn = (error, context) => {
|
|
670
698
|
const nowMs = Date.now();
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertAppStorageWriteAllowed } from "./app-storage-maintenance.js";
|
|
1
2
|
import { randomUUID } from "node:crypto";
|
|
2
3
|
import { DeleteObjectCommand, GetObjectCommand, HeadObjectCommand, PutObjectCommand, S3Client, } from "@aws-sdk/client-s3";
|
|
3
4
|
import { getSignedUrl } from "@aws-sdk/s3-request-presigner";
|
|
@@ -152,6 +153,7 @@ export function isPublishingMediaObjectKeyForOrganization(key, organizationId) {
|
|
|
152
153
|
* asks, plus the single-PUT maximum below, which no plan exceeds.
|
|
153
154
|
*/
|
|
154
155
|
export async function presignImportUpload(input) {
|
|
156
|
+
assertAppStorageWriteAllowed();
|
|
155
157
|
if (!Number.isSafeInteger(input.contentLength) || input.contentLength < 1
|
|
156
158
|
|| input.contentLength > IMPORT_OBJECT_SINGLE_PUT_MAX_BYTES) {
|
|
157
159
|
throw new OxygenError("request_too_large", "Import file is too large to stage in one upload.", {
|
|
@@ -181,6 +183,7 @@ export async function presignImportUpload(input) {
|
|
|
181
183
|
};
|
|
182
184
|
}
|
|
183
185
|
export async function presignPublishingMediaUpload(input) {
|
|
186
|
+
assertAppStorageWriteAllowed();
|
|
184
187
|
const { client, config } = resolveClient();
|
|
185
188
|
const storageKey = buildPublishingMediaObjectKey({
|
|
186
189
|
organizationId: input.organizationId,
|
|
@@ -203,6 +206,7 @@ export async function presignPublishingMediaUpload(input) {
|
|
|
203
206
|
};
|
|
204
207
|
}
|
|
205
208
|
export async function presignCopilotAttachmentUpload(input) {
|
|
209
|
+
assertAppStorageWriteAllowed();
|
|
206
210
|
const { client, config } = resolveClient();
|
|
207
211
|
const storageKey = buildCopilotAttachmentObjectKey({
|
|
208
212
|
organizationId: input.organizationId,
|
|
@@ -331,6 +335,7 @@ export async function presignPublishingMediaDownload(input) {
|
|
|
331
335
|
});
|
|
332
336
|
}
|
|
333
337
|
export async function presignInboxAvatarUpload(input) {
|
|
338
|
+
assertAppStorageWriteAllowed();
|
|
334
339
|
const { client, config } = resolveClient();
|
|
335
340
|
const storageKey = buildInboxAvatarObjectKey({
|
|
336
341
|
organizationId: input.organizationId,
|
|
@@ -363,6 +368,7 @@ export async function presignInboxAvatarUpload(input) {
|
|
|
363
368
|
* re-sniffs: the public route serves this back from our own origin.
|
|
364
369
|
*/
|
|
365
370
|
export async function putInboxAvatarObject(input) {
|
|
371
|
+
assertAppStorageWriteAllowed();
|
|
366
372
|
const { client, config } = resolveClient();
|
|
367
373
|
const storageKey = buildInboxAvatarObjectKey({
|
|
368
374
|
organizationId: input.organizationId,
|
|
@@ -385,6 +391,7 @@ export async function putInboxAvatarObject(input) {
|
|
|
385
391
|
* in a bucket shared by every tenant and several non-avatar namespaces.
|
|
386
392
|
*/
|
|
387
393
|
export async function replaceInboxAvatarObject(input) {
|
|
394
|
+
assertAppStorageWriteAllowed();
|
|
388
395
|
if (!isInboxAvatarObjectKeyForOrganization(input.storageKey, input.organizationId)) {
|
|
389
396
|
throw new OxygenError("invalid_object_key", "Refusing to replace an avatar that belongs to another organization.", { exitCode: 1 });
|
|
390
397
|
}
|
|
@@ -441,6 +448,7 @@ export async function getInboxAvatarObjectMetadata(input) {
|
|
|
441
448
|
* only proves "some org's avatar".
|
|
442
449
|
*/
|
|
443
450
|
export async function deleteInboxAvatarObject(input) {
|
|
451
|
+
assertAppStorageWriteAllowed();
|
|
444
452
|
if (!input.storageKey.startsWith("inbox-avatars/")) {
|
|
445
453
|
throw new OxygenError("invalid_object_key", "Refusing to delete an object outside the inbox-avatars prefix.", { details: { prefix: input.storageKey.split("/")[0] ?? "" }, exitCode: 1 });
|
|
446
454
|
}
|
|
@@ -452,6 +460,7 @@ export async function deleteInboxAvatarObject(input) {
|
|
|
452
460
|
await client.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: input.storageKey }));
|
|
453
461
|
}
|
|
454
462
|
export async function deleteImportObject(input) {
|
|
463
|
+
assertAppStorageWriteAllowed();
|
|
455
464
|
const { client, config } = resolveClient();
|
|
456
465
|
await client.send(new DeleteObjectCommand({ Bucket: config.bucket, Key: input.storageKey }));
|
|
457
466
|
}
|
|
@@ -955,10 +955,14 @@ export type SequenceLintIssue = {
|
|
|
955
955
|
* - `sequence_step_sender_ignored`: a sender field on a step kind that does not
|
|
956
956
|
* take it is not stored. A blind CLI run put `sender_profile_id` on an email
|
|
957
957
|
* step before email took one; it was dropped without a word (2026-09-30).
|
|
958
|
+
* - `sequence_step_field_ignored`: any other field a step kind (or one of its
|
|
959
|
+
* A/B variants) does not take, e.g. `variants` on an invite or `subject_template`
|
|
960
|
+
* on a variant of a threaded email. Steps are rebuilt from known fields, so
|
|
961
|
+
* these read back missing with nothing to say why (2026-10-10).
|
|
958
962
|
*/
|
|
959
963
|
export type SequenceDefinitionNotice = {
|
|
960
964
|
path: string;
|
|
961
|
-
code: "email_reply_rewritten" | "sequence_linkedin_owner_conflict" | "sequence_step_sender_ignored";
|
|
965
|
+
code: "email_reply_rewritten" | "sequence_linkedin_owner_conflict" | "sequence_step_sender_ignored" | "sequence_step_field_ignored";
|
|
962
966
|
message: string;
|
|
963
967
|
};
|
|
964
968
|
export type ValidateSequenceOptions = {
|
|
@@ -1220,6 +1220,8 @@ export function validateSequenceDefinition(input, options = {}) {
|
|
|
1220
1220
|
const step = normalizeStep(rawStep, index, options, issues);
|
|
1221
1221
|
if (!step)
|
|
1222
1222
|
return;
|
|
1223
|
+
if (options.notices)
|
|
1224
|
+
options.notices.push(...ignoredStepFieldNotices(rawStep, step, index));
|
|
1223
1225
|
if (usedIds.has(step.id)) {
|
|
1224
1226
|
issues.push({ path: `steps[${index}].id`, message: `Duplicate step id '${step.id}'.` });
|
|
1225
1227
|
}
|
|
@@ -1755,6 +1757,95 @@ raw, index, options, issues) {
|
|
|
1755
1757
|
return null;
|
|
1756
1758
|
}
|
|
1757
1759
|
}
|
|
1760
|
+
// The fields each step kind stores beyond id/kind/channel and the sender
|
|
1761
|
+
// fields (which carry their own notice). Anything else is dropped on save.
|
|
1762
|
+
const STEP_KIND_FIELDS = {
|
|
1763
|
+
visit_profile: [],
|
|
1764
|
+
invite: ["note_template"],
|
|
1765
|
+
wait_for_connection: ["timeout_days", "on_timeout"],
|
|
1766
|
+
message: ["template", "variants", "auto_optimize", "attachments"],
|
|
1767
|
+
inmail: ["subject_template", "template", "variants", "auto_optimize"],
|
|
1768
|
+
follow: [],
|
|
1769
|
+
like_post: ["reaction", "post_recency_days", "on_no_post"],
|
|
1770
|
+
comment_post: ["text_template", "ai_prompt", "post_recency_days", "on_no_post"],
|
|
1771
|
+
withdraw_invite: [],
|
|
1772
|
+
email_send: ["subject_template", "body_template", "body_html_template", "cc", "bcc", "send_window", "variants", "auto_optimize"],
|
|
1773
|
+
email_reply: ["body_template", "body_html_template", "cc", "bcc", "send_window", "variants", "auto_optimize"],
|
|
1774
|
+
email_enroll: ["subsequence_id"],
|
|
1775
|
+
email_move: ["subsequence_id"],
|
|
1776
|
+
email_stop: [],
|
|
1777
|
+
whatsapp_message: ["template", "variants", "auto_optimize"],
|
|
1778
|
+
call_task: ["note", "priority", "timeout_days", "on_timeout"],
|
|
1779
|
+
crm_task: ["provider", "connection_id", "property_mappings", "record_mappings", "record_links", "associations"],
|
|
1780
|
+
wait: ["days", "hours", "jitter_hours"],
|
|
1781
|
+
wait_for_signal: ["signal", "timeout_days", "on_timeout"],
|
|
1782
|
+
branch: ["condition", "then", "else", "then_id", "else_id", "timeout_days"],
|
|
1783
|
+
stop: [],
|
|
1784
|
+
};
|
|
1785
|
+
const STEP_COMMON_FIELDS = new Set(["id", "kind", "channel", "sender_profile_id", "sender_mailbox_id"]);
|
|
1786
|
+
const A_B_STEP_KINDS = "message, inmail, email_send and whatsapp_message";
|
|
1787
|
+
function meaningfulField(value) {
|
|
1788
|
+
if (value === undefined || value === null || value === "")
|
|
1789
|
+
return false;
|
|
1790
|
+
return !(Array.isArray(value) && value.length === 0);
|
|
1791
|
+
}
|
|
1792
|
+
/** Copy fields an A/B variant of this (normalized) step may vary. */
|
|
1793
|
+
function variantFieldsFor(step) {
|
|
1794
|
+
switch (step.kind) {
|
|
1795
|
+
case "message":
|
|
1796
|
+
case "whatsapp_message":
|
|
1797
|
+
return ["template"];
|
|
1798
|
+
case "inmail":
|
|
1799
|
+
return ["subject_template", "template"];
|
|
1800
|
+
case "email_send":
|
|
1801
|
+
return sequenceEmailStepThreads(step) ? ["body_template", "body_html_template"] : ["subject_template", "body_template", "body_html_template"];
|
|
1802
|
+
default:
|
|
1803
|
+
return [];
|
|
1804
|
+
}
|
|
1805
|
+
}
|
|
1806
|
+
/** Notices for every field of a raw step, or of its variants, that was not stored. */
|
|
1807
|
+
function ignoredStepFieldNotices(raw, step, index) {
|
|
1808
|
+
if (!isRecord(raw) || typeof raw.kind !== "string")
|
|
1809
|
+
return [];
|
|
1810
|
+
const kind = raw.kind;
|
|
1811
|
+
const takes = STEP_KIND_FIELDS[kind] ?? [];
|
|
1812
|
+
const notices = [];
|
|
1813
|
+
for (const [field, value] of Object.entries(raw)) {
|
|
1814
|
+
if (STEP_COMMON_FIELDS.has(field) || takes.includes(field) || !meaningfulField(value))
|
|
1815
|
+
continue;
|
|
1816
|
+
const hint = field === "variants" || field === "auto_optimize"
|
|
1817
|
+
? ` A/B variants go on ${A_B_STEP_KINDS} steps.`
|
|
1818
|
+
: field === "attachments"
|
|
1819
|
+
? " Only LinkedIn message steps take attachments."
|
|
1820
|
+
: takes.length > 0 ? ` ${kind} steps take ${takes.join(", ")}.` : "";
|
|
1821
|
+
notices.push({
|
|
1822
|
+
path: `steps[${index}].${field}`,
|
|
1823
|
+
code: "sequence_step_field_ignored",
|
|
1824
|
+
message: `step '${step.id}': ${kind} steps do not take ${field}, so it was not saved.${hint}`,
|
|
1825
|
+
});
|
|
1826
|
+
}
|
|
1827
|
+
const variants = raw.variants;
|
|
1828
|
+
const variantFields = variantFieldsFor(step);
|
|
1829
|
+
if (Array.isArray(variants) && variantFields.length > 0) {
|
|
1830
|
+
variants.forEach((variant, variantIndex) => {
|
|
1831
|
+
if (!isRecord(variant))
|
|
1832
|
+
return;
|
|
1833
|
+
for (const [field, value] of Object.entries(variant)) {
|
|
1834
|
+
if (variantFields.includes(field) || !meaningfulField(value))
|
|
1835
|
+
continue;
|
|
1836
|
+
const threaded = step.kind === "email_send" && field === "subject_template";
|
|
1837
|
+
notices.push({
|
|
1838
|
+
path: `steps[${index}].variants[${variantIndex}].${field}`,
|
|
1839
|
+
code: "sequence_step_field_ignored",
|
|
1840
|
+
message: threaded
|
|
1841
|
+
? `step '${step.id}': variant ${sequenceVariantLabel(variantIndex + 1)} sets subject_template, but this email has no subject (it continues the thread), so its variants vary only the body. Give the step a subject_template to test subjects.`
|
|
1842
|
+
: `step '${step.id}': variant ${sequenceVariantLabel(variantIndex + 1)} sets ${field}, which a ${step.kind} variant does not vary, so it was not saved. Its variants vary ${variantFields.join(", ")}.`,
|
|
1843
|
+
});
|
|
1844
|
+
}
|
|
1845
|
+
});
|
|
1846
|
+
}
|
|
1847
|
+
return notices;
|
|
1848
|
+
}
|
|
1758
1849
|
const LINKEDIN_POST_REACTION_SET = new Set(["like", "celebrate", "support", "funny", "love", "insightful"]);
|
|
1759
1850
|
/** Max attachments per LinkedIn message (kept small — LinkedIn is not a file host). */
|
|
1760
1851
|
const MAX_LINKEDIN_ATTACHMENTS = 5;
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Removes tool-call markup from text that arrives in chunks. A chunk boundary
|
|
3
|
+
* can fall inside a token, so a suffix that could still become one is held until
|
|
4
|
+
* the next chunk decides it: never more than {@link MAX_HELD_CHARS} characters,
|
|
5
|
+
* and only when it starts with `<`, `[` or `]`.
|
|
6
|
+
*/
|
|
7
|
+
export declare function createToolCallMarkupFilter(): {
|
|
8
|
+
push: (chunk: string) => string;
|
|
9
|
+
flush: () => string;
|
|
10
|
+
/** True once anything has been removed from this stream. */
|
|
11
|
+
removedAny: () => boolean;
|
|
12
|
+
};
|
|
13
|
+
/**
|
|
14
|
+
* The text with every tool-call block and stray template token removed. Text
|
|
15
|
+
* that held none comes back unchanged, byte for byte; where something was cut,
|
|
16
|
+
* the blank lines it leaves collapse and the ends are trimmed.
|
|
17
|
+
*/
|
|
18
|
+
export declare function stripToolCallMarkup(text: string): string;
|
|
19
|
+
/**
|
|
20
|
+
* Did the model try to CALL something in its text? Only a call block counts: a
|
|
21
|
+
* stray end-of-turn token is leakage worth stripping, not an attempted call.
|
|
22
|
+
*/
|
|
23
|
+
export declare function containsToolCallMarkup(text: string): boolean;
|
|
@@ -0,0 +1,213 @@
|
|
|
1
|
+
// Tool calls a model wrote as TEXT instead of making them.
|
|
2
|
+
//
|
|
3
|
+
// Every model behind an OpenAI-compatible endpoint has a native tool-call format
|
|
4
|
+
// that the serving provider is supposed to parse out of the completion and hand
|
|
5
|
+
// back as structured `tool_calls`. When that parse fails -- a template the
|
|
6
|
+
// provider does not know, a spelling the parser does not expect, a block split
|
|
7
|
+
// or left unclosed -- the raw markup arrives in `content` and streams straight
|
|
8
|
+
// to the person reading the reply. Seen in a staff Copilot session on 2026-10-10:
|
|
9
|
+
//
|
|
10
|
+
// I'll check Oxygen's signal sources and the play, then build a morning queue.
|
|
11
|
+
// <|DSML|tool_calls> <|DSML|invoke name="oxygen_knowledge_index"> …
|
|
12
|
+
//
|
|
13
|
+
// Nothing ran: the call never reached the runtime as a call. This module finds
|
|
14
|
+
// that markup and removes it from user-visible text. It is deliberately a closed
|
|
15
|
+
// list of known template tokens rather than a heuristic for "looks like a call":
|
|
16
|
+
// prose that merely mentions a tool, or a JSON example in a code fence, passes
|
|
17
|
+
// through untouched. The DeepSeek family is matched by shape, because its
|
|
18
|
+
// parsers have met spaced (`<|DSML| invoke`), doubled (`<||DSML||tool_calls>`)
|
|
19
|
+
// and slash-less closing spellings in the wild; the others by their literals.
|
|
20
|
+
//
|
|
21
|
+
// Pure and browser-safe: the worker strips the live stream, the runtime strips a
|
|
22
|
+
// final answer, and the web strips rows written before either did.
|
|
23
|
+
function literalMarker(open, close) {
|
|
24
|
+
return {
|
|
25
|
+
find: (text) => {
|
|
26
|
+
const index = text.indexOf(open);
|
|
27
|
+
return index < 0 ? null : { index, match: open };
|
|
28
|
+
},
|
|
29
|
+
...(close === undefined ? {} : { close: close === null ? null : () => new RegExp(escapeRegExp(close)) }),
|
|
30
|
+
couldStart: (tail) => open.startsWith(tail),
|
|
31
|
+
};
|
|
32
|
+
}
|
|
33
|
+
function patternMarker(pattern, partial, close) {
|
|
34
|
+
return {
|
|
35
|
+
find: (text) => {
|
|
36
|
+
const found = pattern.exec(text);
|
|
37
|
+
return found ? { index: found.index, match: found[0] } : null;
|
|
38
|
+
},
|
|
39
|
+
...(close === undefined ? {} : { close }),
|
|
40
|
+
couldStart: (tail) => partial.test(tail),
|
|
41
|
+
};
|
|
42
|
+
}
|
|
43
|
+
function escapeRegExp(value) {
|
|
44
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
45
|
+
}
|
|
46
|
+
// DeepSeek V3.2 and later: `<|DSML|tool_calls>`, `<|DSML|invoke name="…">`,
|
|
47
|
+
// `<|DSML|parameter …>`, closed by the same tag with a slash (or, seen in the
|
|
48
|
+
// wild, without one). One or two bars of either width, spaces anywhere.
|
|
49
|
+
const DSML = String.raw `<\s*\/?\s*[||]{1,2}\s*DSML\s*[||]{1,2}\s*`;
|
|
50
|
+
const DSML_PARTIAL = /^<\s*\/?\s*(?:[||]{1,2}\s*(?:D(?:S(?:M(?:L\s*[||]{0,2}\s*[A-Za-z_]*)?)?)?)?)?$/i;
|
|
51
|
+
const BLOCK_MARKERS = [
|
|
52
|
+
patternMarker(
|
|
53
|
+
// A lookahead rather than `\b`, so `<|DSML|tool` at the end of a chunk
|
|
54
|
+
// waits for the rest of `tool_calls` instead of opening the wrong block.
|
|
55
|
+
/<\s*[||]{1,2}\s*DSML\s*[||]{1,2}\s*(?:tool_calls|tool_call|function_calls|function_call|toolcalls|calls|tool|invoke|parameter)(?=[^A-Za-z0-9_])/i, DSML_PARTIAL, (opened) => {
|
|
56
|
+
const kind = /([A-Za-z_]+)$/.exec(opened)?.[1] ?? "";
|
|
57
|
+
return new RegExp(`${DSML}${escapeRegExp(kind)}\\s*>`, "i");
|
|
58
|
+
}),
|
|
59
|
+
// DeepSeek V3 and R1.
|
|
60
|
+
literalMarker("<|tool▁calls▁begin|>", "<|tool▁calls▁end|>"),
|
|
61
|
+
literalMarker("<|tool▁call▁begin|>", "<|tool▁call▁end|>"),
|
|
62
|
+
// Kimi K2; K3 opens a call with `<|open|>call` and ends the message with it.
|
|
63
|
+
literalMarker("<|tool_calls_section_begin|>", "<|tool_calls_section_end|>"),
|
|
64
|
+
literalMarker("<|tool_call_begin|>", "<|tool_call_end|>"),
|
|
65
|
+
literalMarker("<|open|>call", null),
|
|
66
|
+
// Hermes, Qwen and GLM; Gemma 4.
|
|
67
|
+
literalMarker("<tool_call>", "</tool_call>"),
|
|
68
|
+
literalMarker("<|tool_call>", "<tool_call|>"),
|
|
69
|
+
// MiniMax.
|
|
70
|
+
literalMarker("<minimax:tool_call>", "</minimax:tool_call>"),
|
|
71
|
+
// Anthropic-style XML, which several open models imitate, with and without
|
|
72
|
+
// its namespace prefix.
|
|
73
|
+
literalMarker("<function_calls>", "</function_calls>"),
|
|
74
|
+
literalMarker("<antml:function_calls>", "</antml:function_calls>"),
|
|
75
|
+
// gpt-oss's harmony format.
|
|
76
|
+
literalMarker("<|channel|>commentary to=", "<|call|>"),
|
|
77
|
+
// Llama and Mistral put the call after a marker and end the message there.
|
|
78
|
+
literalMarker("<|python_tag|>", null),
|
|
79
|
+
literalMarker("[TOOL_CALLS]", null),
|
|
80
|
+
];
|
|
81
|
+
/**
|
|
82
|
+
* Lone template tokens with nothing around them to strip: `<|im_end|>`,
|
|
83
|
+
* `<|end▁of▁sentence|>`, `<|Assistant|>`, a DSML tag whose block was already
|
|
84
|
+
* cut, MiniMax M3's call prefix. Never prose.
|
|
85
|
+
*/
|
|
86
|
+
const TOKEN_MARKERS = [
|
|
87
|
+
patternMarker(new RegExp(`${DSML}[^<>\\n]{0,200}>`, "i"), DSML_PARTIAL),
|
|
88
|
+
patternMarker(/<\|[A-Za-z][A-Za-z0-9_]{0,47}\|>/, /^<(?:\|[A-Za-z0-9_]{0,48}\|?)?$/),
|
|
89
|
+
patternMarker(/<|[^|<>\n]{1,48}|>/, /^<(?:|[^|<>\n]{0,48}|?)?$/),
|
|
90
|
+
literalMarker("]<]minimax[>["),
|
|
91
|
+
];
|
|
92
|
+
/** Long enough to hold back any opener or token still arriving. */
|
|
93
|
+
const MAX_HELD_CHARS = 64;
|
|
94
|
+
/** A held tail this long that never completed is a cut-off marker, not prose. */
|
|
95
|
+
const MIN_RESIDUE_CHARS = 3;
|
|
96
|
+
function earliestHit(text) {
|
|
97
|
+
let best = null;
|
|
98
|
+
// Blocks first, and a later marker must start strictly earlier to win: an
|
|
99
|
+
// opener such as `<|tool_call_begin|>` is also a well-formed lone token.
|
|
100
|
+
for (const marker of [...BLOCK_MARKERS, ...TOKEN_MARKERS]) {
|
|
101
|
+
const found = marker.find(text);
|
|
102
|
+
if (!found || (best !== null && found.index >= best.index))
|
|
103
|
+
continue;
|
|
104
|
+
best = {
|
|
105
|
+
index: found.index,
|
|
106
|
+
length: found.match.length,
|
|
107
|
+
close: marker.close === undefined ? undefined : marker.close === null ? null : marker.close(found.match),
|
|
108
|
+
};
|
|
109
|
+
}
|
|
110
|
+
return best;
|
|
111
|
+
}
|
|
112
|
+
/** Where text stops being safe to emit: a suffix that may still become markup. */
|
|
113
|
+
function heldFrom(text) {
|
|
114
|
+
for (let index = Math.max(0, text.length - MAX_HELD_CHARS); index < text.length; index += 1) {
|
|
115
|
+
const char = text[index];
|
|
116
|
+
if (char !== "<" && char !== "[" && char !== "]")
|
|
117
|
+
continue;
|
|
118
|
+
const tail = text.slice(index);
|
|
119
|
+
if ([...BLOCK_MARKERS, ...TOKEN_MARKERS].some((marker) => marker.couldStart(tail)))
|
|
120
|
+
return index;
|
|
121
|
+
}
|
|
122
|
+
return text.length;
|
|
123
|
+
}
|
|
124
|
+
/** `<|open|>` is a whole token and also the start of Kimi's `<|open|>call` block. */
|
|
125
|
+
function couldBecomeBlock(tail) {
|
|
126
|
+
return BLOCK_MARKERS.some((marker) => marker.couldStart(tail));
|
|
127
|
+
}
|
|
128
|
+
/**
|
|
129
|
+
* Removes tool-call markup from text that arrives in chunks. A chunk boundary
|
|
130
|
+
* can fall inside a token, so a suffix that could still become one is held until
|
|
131
|
+
* the next chunk decides it: never more than {@link MAX_HELD_CHARS} characters,
|
|
132
|
+
* and only when it starts with `<`, `[` or `]`.
|
|
133
|
+
*/
|
|
134
|
+
export function createToolCallMarkupFilter() {
|
|
135
|
+
let pending = "";
|
|
136
|
+
// undefined: outside any block. null: inside a block that runs to the end.
|
|
137
|
+
let closing;
|
|
138
|
+
let removed = false;
|
|
139
|
+
const drain = (final) => {
|
|
140
|
+
let out = "";
|
|
141
|
+
for (;;) {
|
|
142
|
+
if (closing !== undefined) {
|
|
143
|
+
const end = closing === null ? null : closing.exec(pending);
|
|
144
|
+
if (!end) {
|
|
145
|
+
// Keep only what could still be the start of the closing tag.
|
|
146
|
+
pending = closing === null || final ? "" : pending.slice(-MAX_HELD_CHARS);
|
|
147
|
+
return out;
|
|
148
|
+
}
|
|
149
|
+
pending = pending.slice(end.index + end[0].length);
|
|
150
|
+
closing = undefined;
|
|
151
|
+
continue;
|
|
152
|
+
}
|
|
153
|
+
const hit = earliestHit(pending);
|
|
154
|
+
if (hit && !final && hit.close === undefined && couldBecomeBlock(pending.slice(hit.index))) {
|
|
155
|
+
// A lone token that the next chunk may yet turn into a block opener.
|
|
156
|
+
out += pending.slice(0, hit.index);
|
|
157
|
+
pending = pending.slice(hit.index);
|
|
158
|
+
return out;
|
|
159
|
+
}
|
|
160
|
+
if (hit) {
|
|
161
|
+
out += pending.slice(0, hit.index);
|
|
162
|
+
pending = pending.slice(hit.index + hit.length);
|
|
163
|
+
closing = hit.close;
|
|
164
|
+
removed = true;
|
|
165
|
+
continue;
|
|
166
|
+
}
|
|
167
|
+
const held = heldFrom(pending);
|
|
168
|
+
if (final) {
|
|
169
|
+
// The stream ended inside a marker: what arrived of it is residue.
|
|
170
|
+
const residue = pending.length - held >= MIN_RESIDUE_CHARS;
|
|
171
|
+
out += residue ? pending.slice(0, held) : pending;
|
|
172
|
+
removed ||= residue;
|
|
173
|
+
pending = "";
|
|
174
|
+
return out;
|
|
175
|
+
}
|
|
176
|
+
out += pending.slice(0, held);
|
|
177
|
+
pending = pending.slice(held);
|
|
178
|
+
return out;
|
|
179
|
+
}
|
|
180
|
+
};
|
|
181
|
+
return {
|
|
182
|
+
push: (chunk) => {
|
|
183
|
+
pending += chunk;
|
|
184
|
+
return drain(false);
|
|
185
|
+
},
|
|
186
|
+
flush: () => {
|
|
187
|
+
const out = drain(true);
|
|
188
|
+
pending = "";
|
|
189
|
+
closing = undefined;
|
|
190
|
+
return out;
|
|
191
|
+
},
|
|
192
|
+
removedAny: () => removed,
|
|
193
|
+
};
|
|
194
|
+
}
|
|
195
|
+
/**
|
|
196
|
+
* The text with every tool-call block and stray template token removed. Text
|
|
197
|
+
* that held none comes back unchanged, byte for byte; where something was cut,
|
|
198
|
+
* the blank lines it leaves collapse and the ends are trimmed.
|
|
199
|
+
*/
|
|
200
|
+
export function stripToolCallMarkup(text) {
|
|
201
|
+
const filter = createToolCallMarkupFilter();
|
|
202
|
+
const out = filter.push(text) + filter.flush();
|
|
203
|
+
if (!filter.removedAny())
|
|
204
|
+
return text;
|
|
205
|
+
return out.replace(/\n[ \t]*\n(?:[ \t]*\n)+/g, "\n\n").trim();
|
|
206
|
+
}
|
|
207
|
+
/**
|
|
208
|
+
* Did the model try to CALL something in its text? Only a call block counts: a
|
|
209
|
+
* stray end-of-turn token is leakage worth stripping, not an attempted call.
|
|
210
|
+
*/
|
|
211
|
+
export function containsToolCallMarkup(text) {
|
|
212
|
+
return BLOCK_MARKERS.some((marker) => marker.find(text) !== null);
|
|
213
|
+
}
|
|
@@ -5,6 +5,11 @@ import { getCapabilityRoute, getCapabilityRouteMatch, inferCapabilityRoute, } fr
|
|
|
5
5
|
// the user-facing boundary while preserving table joins and channel-specific
|
|
6
6
|
// connection routes.
|
|
7
7
|
export function inferUserCapabilityRoute(query) {
|
|
8
|
+
if (/\b(ongoing worker|ongoing session|long horizon agent|campaign monitor|watch (?:a |one |this |the |specific )?campaign)\b/i.test(query)) {
|
|
9
|
+
const route = getCapabilityRouteMatch("agents");
|
|
10
|
+
if (route)
|
|
11
|
+
return { ...route, recommendedTools: ["oxygen_agent_session_templates", "oxygen_agent_session_create", "oxygen_agent_session_get", ...route.recommendedTools], recommendedCommands: ["agent session templates", "agent session create", "agent session get", ...route.recommendedCommands], exactCommand: "oxygen agent session templates --slug workspace --json", exactMcpCall: { tool: "oxygen_agent_session_templates", arguments: { slug: "workspace" } } };
|
|
12
|
+
}
|
|
8
13
|
if (isProviderCredentialSetupIntent(query.toLowerCase())) {
|
|
9
14
|
const setup = getCapabilityRouteMatch("connected-integrations");
|
|
10
15
|
if (setup)
|
|
@@ -1 +1 @@
|
|
|
1
|
-
export declare const OXYGEN_BUILD_VERSION = "1.
|
|
1
|
+
export declare const OXYGEN_BUILD_VERSION = "1.1014.18";
|
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
// GENERATED by scripts/ci/version-stamp.mjs from VERSION + git history. Do not edit; do not commit.
|
|
2
|
-
export const OXYGEN_BUILD_VERSION = "1.
|
|
2
|
+
export const OXYGEN_BUILD_VERSION = "1.1014.18";
|
|
@@ -49,7 +49,7 @@ export const OXYGEN_VERSION = OXYGEN_BUILD_VERSION;
|
|
|
49
49
|
// Since 2026-09-28 the release-preparation step advances this literal to the
|
|
50
50
|
// highest CLI version published on npm for at least 30 days. It never lowers
|
|
51
51
|
// the floor. The source change lands on dev before the next production release.
|
|
52
|
-
export const OXYGEN_MINIMUM_CLI_VERSION = "1.
|
|
52
|
+
export const OXYGEN_MINIMUM_CLI_VERSION = "1.948.1";
|
|
53
53
|
// The internal GEO export first gains required pagination in this dev version.
|
|
54
54
|
// Earlier pilot clients would save a partial file without its continuation.
|
|
55
55
|
export const GEO_EXPORT_MINIMUM_CLI_VERSION = "1.1013.0";
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import { assertAppStorageWriteAllowed } from "./app-storage-maintenance.js";
|
|
1
2
|
import { Buffer } from "node:buffer";
|
|
2
3
|
import { createHash } from "node:crypto";
|
|
3
4
|
import { DeleteObjectCommand, GetObjectCommand, PutObjectCommand } from "@aws-sdk/client-s3";
|
|
@@ -163,6 +164,7 @@ export const WORKSPACE_FILE_UPLOAD_PRESIGN_SECONDS = 900;
|
|
|
163
164
|
* is signed by the presigner from ContentLength.
|
|
164
165
|
*/
|
|
165
166
|
export async function presignWorkspaceFileUpload(input) {
|
|
167
|
+
assertAppStorageWriteAllowed();
|
|
166
168
|
if (!Number.isSafeInteger(input.sizeBytes) || input.sizeBytes < 0 || input.sizeBytes > WORKSPACE_OPAQUE_FILE_MAX_BYTES) {
|
|
167
169
|
throw new OxygenError("workspace_file_too_large", "File exceeds 100 MiB.");
|
|
168
170
|
}
|
|
@@ -176,6 +178,7 @@ export async function presignWorkspaceFileUpload(input) {
|
|
|
176
178
|
}
|
|
177
179
|
/** Immutable PUT followed by bounded readback. A matching existing object is a safe retry. */
|
|
178
180
|
export async function putWorkspaceFileObject(input) {
|
|
181
|
+
assertAppStorageWriteAllowed();
|
|
179
182
|
input.signal?.throwIfAborted();
|
|
180
183
|
assertWorkspaceStoredFile(input.bytes, input.mimeType);
|
|
181
184
|
const storageKey = buildWorkspaceFileObjectKey(input);
|
|
@@ -199,6 +202,7 @@ export async function putWorkspaceFileObject(input) {
|
|
|
199
202
|
}
|
|
200
203
|
/** Delete only an object key already proven to belong to this workspace file. */
|
|
201
204
|
export async function deleteWorkspaceFileObject(input) {
|
|
205
|
+
assertAppStorageWriteAllowed();
|
|
202
206
|
assertWorkspaceFileObjectKey(input);
|
|
203
207
|
const { client, bucket } = resolveObjectStorageClient();
|
|
204
208
|
await client.send(new DeleteObjectCommand({ Bucket: bucket, Key: input.storageKey }), {
|