@agenticmail/core 0.9.25 → 0.9.27
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/dist/index.cjs +776 -7
- package/dist/index.d.cts +456 -161
- package/dist/index.d.ts +456 -161
- package/dist/index.js +776 -7
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -7518,6 +7518,22 @@ var PHONE_SERVER_MAX_CALL_DURATION_SECONDS = 3600;
|
|
|
7518
7518
|
var PHONE_SERVER_MAX_COST_PER_MISSION = 5;
|
|
7519
7519
|
var PHONE_SERVER_MAX_ATTEMPTS = 3;
|
|
7520
7520
|
var PHONE_TASK_MAX_LENGTH = 2e3;
|
|
7521
|
+
var PHONE_SERVER_MAX_EXTENSION_SECONDS_PER_REQUEST = 300;
|
|
7522
|
+
var PHONE_SERVER_MAX_EXTENSION_REQUESTS_PER_CALL = 4;
|
|
7523
|
+
var PHONE_SERVER_MAX_TOTAL_EXTENSION_SECONDS = 600;
|
|
7524
|
+
var DEFAULT_EXTENSION_POLICY = {
|
|
7525
|
+
maxSecondsPerRequest: 120,
|
|
7526
|
+
// 2 minutes
|
|
7527
|
+
maxRequestsPerCall: 2,
|
|
7528
|
+
maxTotalExtensionSeconds: 300
|
|
7529
|
+
// 5 minutes
|
|
7530
|
+
};
|
|
7531
|
+
var PHONE_SERVER_MAX_CALLBACK_CHAIN = 3;
|
|
7532
|
+
var DEFAULT_CALLBACK_POLICY = {
|
|
7533
|
+
allowAutoCallback: true,
|
|
7534
|
+
maxCallbackChain: 2
|
|
7535
|
+
};
|
|
7536
|
+
var PHONE_CALLBACK_MAX_DELAY_SECONDS = 7 * 24 * 60 * 60;
|
|
7521
7537
|
var EU_DIAL_PREFIXES = [
|
|
7522
7538
|
"+30",
|
|
7523
7539
|
"+31",
|
|
@@ -7632,6 +7648,76 @@ function validateAlternativePolicy(value) {
|
|
|
7632
7648
|
}
|
|
7633
7649
|
return [];
|
|
7634
7650
|
}
|
|
7651
|
+
function validateExtensionPolicy(value) {
|
|
7652
|
+
if (value === void 0) return [];
|
|
7653
|
+
if (!isRecord(value)) {
|
|
7654
|
+
return [issue("invalid-extension-policy", "policy.extensionPolicy", "extensionPolicy must be an object")];
|
|
7655
|
+
}
|
|
7656
|
+
const issues = [];
|
|
7657
|
+
const perReq = readPositiveInteger(value.maxSecondsPerRequest);
|
|
7658
|
+
if (perReq === null) {
|
|
7659
|
+
issues.push(issue(
|
|
7660
|
+
"invalid-extension-per-request",
|
|
7661
|
+
"policy.extensionPolicy.maxSecondsPerRequest",
|
|
7662
|
+
"maxSecondsPerRequest must be a positive integer"
|
|
7663
|
+
));
|
|
7664
|
+
}
|
|
7665
|
+
const requests = readPositiveInteger(value.maxRequestsPerCall);
|
|
7666
|
+
if (requests === null) {
|
|
7667
|
+
issues.push(issue(
|
|
7668
|
+
"invalid-extension-requests",
|
|
7669
|
+
"policy.extensionPolicy.maxRequestsPerCall",
|
|
7670
|
+
"maxRequestsPerCall must be a positive integer"
|
|
7671
|
+
));
|
|
7672
|
+
}
|
|
7673
|
+
const total = readPositiveInteger(value.maxTotalExtensionSeconds);
|
|
7674
|
+
if (total === null) {
|
|
7675
|
+
issues.push(issue(
|
|
7676
|
+
"invalid-extension-total",
|
|
7677
|
+
"policy.extensionPolicy.maxTotalExtensionSeconds",
|
|
7678
|
+
"maxTotalExtensionSeconds must be a positive integer"
|
|
7679
|
+
));
|
|
7680
|
+
}
|
|
7681
|
+
return issues;
|
|
7682
|
+
}
|
|
7683
|
+
function validateCallbackPolicy(value) {
|
|
7684
|
+
if (value === void 0) return [];
|
|
7685
|
+
if (!isRecord(value)) {
|
|
7686
|
+
return [issue("invalid-callback-policy", "policy.callbackPolicy", "callbackPolicy must be an object")];
|
|
7687
|
+
}
|
|
7688
|
+
const issues = [];
|
|
7689
|
+
if (readBoolean(value.allowAutoCallback) === null) {
|
|
7690
|
+
issues.push(issue(
|
|
7691
|
+
"invalid-callback-allow",
|
|
7692
|
+
"policy.callbackPolicy.allowAutoCallback",
|
|
7693
|
+
"allowAutoCallback must be boolean"
|
|
7694
|
+
));
|
|
7695
|
+
}
|
|
7696
|
+
const chain = readNonNegativeNumber(value.maxCallbackChain);
|
|
7697
|
+
if (chain === null || !Number.isInteger(chain)) {
|
|
7698
|
+
issues.push(issue(
|
|
7699
|
+
"invalid-callback-chain",
|
|
7700
|
+
"policy.callbackPolicy.maxCallbackChain",
|
|
7701
|
+
"maxCallbackChain must be a non-negative integer"
|
|
7702
|
+
));
|
|
7703
|
+
}
|
|
7704
|
+
return issues;
|
|
7705
|
+
}
|
|
7706
|
+
function resolveExtensionPolicy(input) {
|
|
7707
|
+
const src = input ?? DEFAULT_EXTENSION_POLICY;
|
|
7708
|
+
return {
|
|
7709
|
+
maxSecondsPerRequest: Math.min(src.maxSecondsPerRequest, PHONE_SERVER_MAX_EXTENSION_SECONDS_PER_REQUEST),
|
|
7710
|
+
maxRequestsPerCall: Math.min(src.maxRequestsPerCall, PHONE_SERVER_MAX_EXTENSION_REQUESTS_PER_CALL),
|
|
7711
|
+
maxTotalExtensionSeconds: Math.min(src.maxTotalExtensionSeconds, PHONE_SERVER_MAX_TOTAL_EXTENSION_SECONDS)
|
|
7712
|
+
};
|
|
7713
|
+
}
|
|
7714
|
+
function resolveCallbackPolicy(input) {
|
|
7715
|
+
const src = input ?? DEFAULT_CALLBACK_POLICY;
|
|
7716
|
+
return {
|
|
7717
|
+
allowAutoCallback: src.allowAutoCallback,
|
|
7718
|
+
maxCallbackChain: Math.min(src.maxCallbackChain, PHONE_SERVER_MAX_CALLBACK_CHAIN)
|
|
7719
|
+
};
|
|
7720
|
+
}
|
|
7635
7721
|
function validatePhoneMissionPolicy(policy) {
|
|
7636
7722
|
const issues = [];
|
|
7637
7723
|
if (!isRecord(policy)) {
|
|
@@ -7666,6 +7752,8 @@ function validatePhoneMissionPolicy(policy) {
|
|
|
7666
7752
|
}
|
|
7667
7753
|
issues.push(...validateConfirmPolicy(policy.confirmPolicy));
|
|
7668
7754
|
issues.push(...validateAlternativePolicy(policy.alternativePolicy));
|
|
7755
|
+
issues.push(...validateExtensionPolicy(policy.extensionPolicy));
|
|
7756
|
+
issues.push(...validateCallbackPolicy(policy.callbackPolicy));
|
|
7669
7757
|
if (issues.length > 0) return { ok: false, issues };
|
|
7670
7758
|
return {
|
|
7671
7759
|
ok: true,
|
|
@@ -7680,7 +7768,14 @@ function validatePhoneMissionPolicy(policy) {
|
|
|
7680
7768
|
confirmPolicy: policy.confirmPolicy,
|
|
7681
7769
|
alternativePolicy: {
|
|
7682
7770
|
maxTimeShiftMinutes: policy.alternativePolicy.maxTimeShiftMinutes
|
|
7683
|
-
}
|
|
7771
|
+
},
|
|
7772
|
+
// The extension + callback policies are optional in the caller's
|
|
7773
|
+
// input but we ALWAYS materialise them in the resolved policy so
|
|
7774
|
+
// every downstream consumer (the bridge, the scheduler, the
|
|
7775
|
+
// manager) can read a concrete value without juggling undefined.
|
|
7776
|
+
// Caller-omitted → DEFAULT_*. Caller-set → clamped to server caps.
|
|
7777
|
+
extensionPolicy: resolveExtensionPolicy(policy.extensionPolicy),
|
|
7778
|
+
callbackPolicy: resolveCallbackPolicy(policy.callbackPolicy)
|
|
7684
7779
|
},
|
|
7685
7780
|
issues: []
|
|
7686
7781
|
};
|
|
@@ -7915,6 +8010,13 @@ function readOperatorQueries(mission) {
|
|
|
7915
8010
|
if (!Array.isArray(value)) return [];
|
|
7916
8011
|
return value.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item) && typeof item.id === "string" && typeof item.question === "string");
|
|
7917
8012
|
}
|
|
8013
|
+
function readChainDepth(mission) {
|
|
8014
|
+
const raw = mission.metadata.callbackChainDepth;
|
|
8015
|
+
if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return Math.floor(raw);
|
|
8016
|
+
const sc = mission.metadata.scheduledCallback;
|
|
8017
|
+
if (sc && Number.isFinite(sc.chainDepth)) return Math.floor(sc.chainDepth);
|
|
8018
|
+
return 0;
|
|
8019
|
+
}
|
|
7918
8020
|
function escapeLike(value) {
|
|
7919
8021
|
return value.replace(/[\\%_]/g, "\\$&");
|
|
7920
8022
|
}
|
|
@@ -7933,6 +8035,26 @@ function buildCallbackTask(originalTask, query) {
|
|
|
7933
8035
|
return `${continuity}
|
|
7934
8036
|
${originalTask.slice(0, room)}`.slice(0, PHONE_TASK_MAX_LENGTH);
|
|
7935
8037
|
}
|
|
8038
|
+
function buildScheduledCallbackTask(originalTask, payload) {
|
|
8039
|
+
const continuity = [
|
|
8040
|
+
"# Call continuity \u2014 auto-callback",
|
|
8041
|
+
'You were on a call with this person earlier and scheduled this follow-up before signing off. Open by acknowledging it naturally \u2014 e.g. "Hi, this is <your name> calling back as I said I would." Then continue the conversation from where you left off.',
|
|
8042
|
+
"",
|
|
8043
|
+
`## Why you arranged this callback
|
|
8044
|
+
${payload.reason}`,
|
|
8045
|
+
"",
|
|
8046
|
+
`## Notes you left for yourself (your own summary at the end of the prior call)
|
|
8047
|
+
${payload.agentSummary}`,
|
|
8048
|
+
"",
|
|
8049
|
+
`## What was actually said on the prior call (verbatim digest)
|
|
8050
|
+
${payload.transcriptDigest}`,
|
|
8051
|
+
"",
|
|
8052
|
+
"# Original task"
|
|
8053
|
+
].join("\n");
|
|
8054
|
+
const room = Math.max(0, PHONE_TASK_MAX_LENGTH - continuity.length - 1);
|
|
8055
|
+
return `${continuity}
|
|
8056
|
+
${originalTask.slice(0, room)}`.slice(0, PHONE_TASK_MAX_LENGTH);
|
|
8057
|
+
}
|
|
7936
8058
|
function parseJson(value, fallback) {
|
|
7937
8059
|
if (!value) return fallback;
|
|
7938
8060
|
try {
|
|
@@ -8605,6 +8727,134 @@ var PhoneManager = class {
|
|
|
8605
8727
|
throw err;
|
|
8606
8728
|
}
|
|
8607
8729
|
}
|
|
8730
|
+
// ─── Scheduled callbacks (v0.9.81 — schedule_callback tool) ──────────
|
|
8731
|
+
/**
|
|
8732
|
+
* Persist a `schedule_callback` request to the mission. Called from
|
|
8733
|
+
* the realtime bridge's `onCallbackScheduled` hook. The scheduler
|
|
8734
|
+
* picks this up later when `payload.at <= now`. ChainDepth is
|
|
8735
|
+
* computed from the parent's metadata so {@link triggerScheduledCallback}
|
|
8736
|
+
* can enforce policy.callbackPolicy.maxCallbackChain without
|
|
8737
|
+
* walking back through the mission history.
|
|
8738
|
+
*
|
|
8739
|
+
* Returns the updated mission, or null if the mission isn't known.
|
|
8740
|
+
* Idempotent on the `mission.metadata.scheduledCallback.at` key: if
|
|
8741
|
+
* a scheduled callback already exists on the mission this writes a
|
|
8742
|
+
* SECOND copy on `scheduledCallbacks` as an audit trail but does
|
|
8743
|
+
* NOT overwrite the active record (the bridge only allows one per
|
|
8744
|
+
* call anyway; the audit log is a belt-and-braces guard against
|
|
8745
|
+
* the unusual case where a server restart re-runs the bridge logic).
|
|
8746
|
+
*/
|
|
8747
|
+
armScheduledCallback(missionId, payload) {
|
|
8748
|
+
const mission = this.getMission(missionId);
|
|
8749
|
+
if (!mission) return null;
|
|
8750
|
+
const parentDepth = readChainDepth(mission);
|
|
8751
|
+
const record = {
|
|
8752
|
+
at: payload.at,
|
|
8753
|
+
reason: payload.reason,
|
|
8754
|
+
agentSummary: payload.agentSummary,
|
|
8755
|
+
transcriptDigest: payload.transcriptDigest,
|
|
8756
|
+
chainDepth: parentDepth + 1,
|
|
8757
|
+
status: "pending",
|
|
8758
|
+
armedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
8759
|
+
};
|
|
8760
|
+
return this.updateMissionStatus(mission.id, mission.status, {
|
|
8761
|
+
scheduledCallback: record
|
|
8762
|
+
}, [{
|
|
8763
|
+
at: record.armedAt,
|
|
8764
|
+
source: "system",
|
|
8765
|
+
text: `Scheduled callback armed for ${record.at} (chain depth ${record.chainDepth}). Reason: ${record.reason}`,
|
|
8766
|
+
metadata: { scheduledAt: record.at, chainDepth: record.chainDepth }
|
|
8767
|
+
}]);
|
|
8768
|
+
}
|
|
8769
|
+
/**
|
|
8770
|
+
* All missions with a `scheduledCallback.status === 'pending'` whose
|
|
8771
|
+
* `at` is <= now. The scheduler's per-tick worklist. Pass an upper
|
|
8772
|
+
* bound on count so a backlog doesn't dial every overdue callback in
|
|
8773
|
+
* one frame.
|
|
8774
|
+
*/
|
|
8775
|
+
findDueScheduledCallbacks(nowIso, limit = 16) {
|
|
8776
|
+
const rows = this.db.prepare(
|
|
8777
|
+
"SELECT * FROM phone_missions WHERE metadata_json LIKE '%scheduledCallback%' AND metadata_json LIKE '%pending%' LIMIT ?"
|
|
8778
|
+
).all(limit * 4);
|
|
8779
|
+
return rows.map(rowToMission).filter((mission) => {
|
|
8780
|
+
const sc = mission.metadata.scheduledCallback;
|
|
8781
|
+
return sc && sc.status === "pending" && sc.at <= nowIso;
|
|
8782
|
+
}).slice(0, limit);
|
|
8783
|
+
}
|
|
8784
|
+
/**
|
|
8785
|
+
* Dial a due scheduled callback. Mirrors {@link triggerCallback} for
|
|
8786
|
+
* the operator-query path:
|
|
8787
|
+
*
|
|
8788
|
+
* 1. Reject if the mission's policy.callbackPolicy disallows it OR
|
|
8789
|
+
* `chainDepth > maxCallbackChain` (no infinite chains).
|
|
8790
|
+
* 2. Transition status pending → dialing BEFORE dialing so a
|
|
8791
|
+
* concurrent tick can't double-dial.
|
|
8792
|
+
* 3. Build the continuation task with prior-call context and dial.
|
|
8793
|
+
* 4. On success: write `status: 'fired'` + the new mission id.
|
|
8794
|
+
* 5. On failure: write `status: 'pending'` + `lastError` so the
|
|
8795
|
+
* next tick can retry, then rethrow.
|
|
8796
|
+
*
|
|
8797
|
+
* Returns `null` if the mission isn't known or has no due callback.
|
|
8798
|
+
*/
|
|
8799
|
+
async triggerScheduledCallback(missionId, options = {}) {
|
|
8800
|
+
const mission = this.getMission(missionId);
|
|
8801
|
+
if (!mission) return null;
|
|
8802
|
+
const sc = mission.metadata.scheduledCallback;
|
|
8803
|
+
if (!sc) return null;
|
|
8804
|
+
if (sc.status !== "pending") return null;
|
|
8805
|
+
const callbackPol = mission.policy.callbackPolicy;
|
|
8806
|
+
if (!callbackPol || !callbackPol.allowAutoCallback || sc.chainDepth > callbackPol.maxCallbackChain) {
|
|
8807
|
+
const updated = this.updateMissionStatus(mission.id, mission.status, {
|
|
8808
|
+
scheduledCallback: { ...sc, status: "failed", lastError: "policy denies callback (chain or disabled)" }
|
|
8809
|
+
}, [{
|
|
8810
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8811
|
+
source: "system",
|
|
8812
|
+
text: `Scheduled callback denied by policy (chainDepth=${sc.chainDepth}, max=${callbackPol?.maxCallbackChain ?? 0}).`
|
|
8813
|
+
}]);
|
|
8814
|
+
return updated ? { mission: updated, callbackMission: updated } : null;
|
|
8815
|
+
}
|
|
8816
|
+
this.updateMissionStatus(mission.id, mission.status, {
|
|
8817
|
+
scheduledCallback: { ...sc, status: "dialing" }
|
|
8818
|
+
}, [{
|
|
8819
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8820
|
+
source: "system",
|
|
8821
|
+
text: `Dialing scheduled callback (chain depth ${sc.chainDepth}).`
|
|
8822
|
+
}]);
|
|
8823
|
+
try {
|
|
8824
|
+
const result = await this.startMission(mission.agentId, {
|
|
8825
|
+
to: mission.to,
|
|
8826
|
+
task: buildScheduledCallbackTask(mission.task, {
|
|
8827
|
+
reason: sc.reason,
|
|
8828
|
+
agentSummary: sc.agentSummary,
|
|
8829
|
+
transcriptDigest: sc.transcriptDigest
|
|
8830
|
+
}),
|
|
8831
|
+
policy: mission.policy
|
|
8832
|
+
}, options);
|
|
8833
|
+
const callbackMission = this.updateMissionStatus(result.mission.id, result.mission.status, {
|
|
8834
|
+
callbackChainDepth: sc.chainDepth,
|
|
8835
|
+
callbackParentMissionId: mission.id
|
|
8836
|
+
}, []) ?? result.mission;
|
|
8837
|
+
const linked = this.updateMissionStatus(mission.id, mission.status, {
|
|
8838
|
+
scheduledCallback: {
|
|
8839
|
+
...sc,
|
|
8840
|
+
status: "fired",
|
|
8841
|
+
firedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8842
|
+
callbackMissionId: result.mission.id
|
|
8843
|
+
}
|
|
8844
|
+
}, []);
|
|
8845
|
+
return { mission: linked, callbackMission };
|
|
8846
|
+
} catch (err) {
|
|
8847
|
+
const message = err?.message ?? String(err);
|
|
8848
|
+
this.updateMissionStatus(mission.id, mission.status, {
|
|
8849
|
+
scheduledCallback: { ...sc, status: "pending", lastError: message }
|
|
8850
|
+
}, [{
|
|
8851
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
8852
|
+
source: "system",
|
|
8853
|
+
text: `Scheduled callback dial failed (${message}); will retry on next scheduler tick.`
|
|
8854
|
+
}]);
|
|
8855
|
+
throw err;
|
|
8856
|
+
}
|
|
8857
|
+
}
|
|
8608
8858
|
build46ElksCallRequest(config, mission) {
|
|
8609
8859
|
const timeout = Math.min(Math.max(mission.policy.maxCallDurationSeconds, 1), PHONE_SERVER_MAX_CALL_DURATION_SECONDS);
|
|
8610
8860
|
return {
|
|
@@ -8745,7 +8995,8 @@ function buildPhoneTransportConfig(input) {
|
|
|
8745
8995
|
}
|
|
8746
8996
|
}
|
|
8747
8997
|
const capabilities = Array.isArray(input.capabilities) ? input.capabilities.filter((item) => typeof item === "string" && ["sms", "call_control", "realtime_media", "recording_supported"].includes(item)) : ["call_control"];
|
|
8748
|
-
const
|
|
8998
|
+
const defaultRegions = isTwilio ? ["WORLD"] : ["EU"];
|
|
8999
|
+
const supportedRegions = Array.isArray(input.supportedRegions) ? input.supportedRegions.filter((item) => typeof item === "string" && ["AT", "DE", "EU", "WORLD"].includes(item)) : defaultRegions;
|
|
8749
9000
|
const config = {
|
|
8750
9001
|
provider,
|
|
8751
9002
|
phoneNumber,
|
|
@@ -8755,7 +9006,7 @@ function buildPhoneTransportConfig(input) {
|
|
|
8755
9006
|
webhookSecret,
|
|
8756
9007
|
apiUrl: apiUrl || void 0,
|
|
8757
9008
|
capabilities: Array.from(/* @__PURE__ */ new Set(["call_control", ...capabilities])),
|
|
8758
|
-
supportedRegions: supportedRegions.length ? Array.from(new Set(supportedRegions)) :
|
|
9009
|
+
supportedRegions: supportedRegions.length ? Array.from(new Set(supportedRegions)) : defaultRegions,
|
|
8759
9010
|
configuredAt: input.configuredAt ?? (/* @__PURE__ */ new Date()).toISOString()
|
|
8760
9011
|
};
|
|
8761
9012
|
const validation = validatePhoneTransportProfile(config);
|
|
@@ -10443,6 +10694,57 @@ var LOAD_SKILL_TOOL = {
|
|
|
10443
10694
|
additionalProperties: false
|
|
10444
10695
|
}
|
|
10445
10696
|
};
|
|
10697
|
+
var GET_CALL_STATUS_TOOL = {
|
|
10698
|
+
type: "function",
|
|
10699
|
+
name: "get_call_status",
|
|
10700
|
+
description: "Check how much time you have left on this call and whether you can request more time or schedule a callback. Use this whenever you are unsure if there is room to keep going. Returns: secondsRemaining, soft deadline timestamp, extensions used/remaining/availableSeconds, callbackAvailable.",
|
|
10701
|
+
parameters: {
|
|
10702
|
+
type: "object",
|
|
10703
|
+
properties: {}
|
|
10704
|
+
}
|
|
10705
|
+
};
|
|
10706
|
+
var EXTEND_CALL_TIME_TOOL = {
|
|
10707
|
+
type: "function",
|
|
10708
|
+
name: "extend_call_time",
|
|
10709
|
+
description: "Request more time on this call. Auto-approved within your call's extension policy \u2014 you do not need to ask the operator. Use this BEFORE your time runs out. If the grant is partial or refused the response tells you exactly what you have left; do not retry-loop.",
|
|
10710
|
+
parameters: {
|
|
10711
|
+
type: "object",
|
|
10712
|
+
properties: {
|
|
10713
|
+
seconds: {
|
|
10714
|
+
type: "number",
|
|
10715
|
+
description: "How many MORE seconds you need. Positive integer. The server caps each grant."
|
|
10716
|
+
},
|
|
10717
|
+
reason: {
|
|
10718
|
+
type: "string",
|
|
10719
|
+
description: "One short line on why \u2014 kept on the mission transcript for review."
|
|
10720
|
+
}
|
|
10721
|
+
},
|
|
10722
|
+
required: ["seconds"]
|
|
10723
|
+
}
|
|
10724
|
+
};
|
|
10725
|
+
var SCHEDULE_CALLBACK_TOOL = {
|
|
10726
|
+
type: "function",
|
|
10727
|
+
name: "schedule_callback",
|
|
10728
|
+
description: 'Arrange an auto-callback to this same number at a later time. Use this when you cannot finish in the time available, or when the caller needs a break, or when context dictates a follow-up ("call me after 5pm", "try again tomorrow"). The next call picks up with your summary + the transcript so far automatically \u2014 say goodbye and the system handles re-dialing. Only ONE callback can be scheduled per call; choose carefully.',
|
|
10729
|
+
parameters: {
|
|
10730
|
+
type: "object",
|
|
10731
|
+
properties: {
|
|
10732
|
+
delay_seconds: {
|
|
10733
|
+
type: "number",
|
|
10734
|
+
description: 'How many seconds from now to call back. Minimum 30, maximum 604800 (7 days). Use larger delays generously \u2014 "tomorrow morning" is ~57600s.'
|
|
10735
|
+
},
|
|
10736
|
+
reason: {
|
|
10737
|
+
type: "string",
|
|
10738
|
+
description: 'One short line for the audit trail \u2014 e.g. "ran out of time before confirming the booking" or "caller asked me to ring back after 5pm".'
|
|
10739
|
+
},
|
|
10740
|
+
summary_for_next_call: {
|
|
10741
|
+
type: "string",
|
|
10742
|
+
description: "What the next call agent MUST know to pick up where you left off \u2014 facts you collected, the caller's name, what was agreed, what is still open. Be concrete; this is all the next agent has from your call."
|
|
10743
|
+
}
|
|
10744
|
+
},
|
|
10745
|
+
required: ["delay_seconds", "summary_for_next_call"]
|
|
10746
|
+
}
|
|
10747
|
+
};
|
|
10446
10748
|
var REALTIME_TOOL_DEFINITIONS = {
|
|
10447
10749
|
ask_operator: ASK_OPERATOR_TOOL,
|
|
10448
10750
|
web_search: WEB_SEARCH_TOOL,
|
|
@@ -10450,7 +10752,10 @@ var REALTIME_TOOL_DEFINITIONS = {
|
|
|
10450
10752
|
get_datetime: GET_DATETIME_TOOL,
|
|
10451
10753
|
search_email: SEARCH_EMAIL_TOOL,
|
|
10452
10754
|
search_skills: SEARCH_SKILLS_TOOL,
|
|
10453
|
-
load_skill: LOAD_SKILL_TOOL
|
|
10755
|
+
load_skill: LOAD_SKILL_TOOL,
|
|
10756
|
+
get_call_status: GET_CALL_STATUS_TOOL,
|
|
10757
|
+
extend_call_time: EXTEND_CALL_TIME_TOOL,
|
|
10758
|
+
schedule_callback: SCHEDULE_CALLBACK_TOOL
|
|
10454
10759
|
};
|
|
10455
10760
|
function buildRealtimeToolGuidance(tools) {
|
|
10456
10761
|
if (tools.length === 0) return "";
|
|
@@ -10479,6 +10784,24 @@ function buildRealtimeToolGuidance(tools) {
|
|
|
10479
10784
|
A skill's rendered playbook is now part of your instructions for the rest of the call. You can load a second skill if a new situation comes up \u2014 but the model keeps a max of two loaded; a third load drops the oldest. Pick skills deliberately.`
|
|
10480
10785
|
);
|
|
10481
10786
|
}
|
|
10787
|
+
if (names.has("get_call_status") || names.has("extend_call_time") || names.has("schedule_callback")) {
|
|
10788
|
+
lines.push(
|
|
10789
|
+
"# Managing your time on this call",
|
|
10790
|
+
"You have a fixed time budget. The system will quietly remind you when you have about 2 minutes left and again at about 30 seconds left \u2014 pace your conversation so you can wrap up cleanly.",
|
|
10791
|
+
"",
|
|
10792
|
+
"If you need more time to finish the job:",
|
|
10793
|
+
`- Call extend_call_time({ seconds: 120, reason: "..." }) BEFORE you run out. Requests are auto-approved within the call's extension policy \u2014 you do not need to ask the operator. The response tells you exactly how much you got and how much extension budget remains.`,
|
|
10794
|
+
"",
|
|
10795
|
+
"If you cannot finish in time, the caller wants you to call back later, OR you sense the conversation has hit a natural pause that needs a follow-up:",
|
|
10796
|
+
`- Call schedule_callback({ delay_seconds: <when>, reason: "...", summary_for_next_call: "..." }) BEFORE you sign off. The summary you pass becomes the next call's starting context \u2014 be concrete: caller's name, what was agreed, what's still open, any commitments you made.`,
|
|
10797
|
+
`- Then politely close: "Thanks for your time \u2014 I'll call you back at <when>." The system dials back automatically with your summary + the conversation so far loaded for the next agent.`,
|
|
10798
|
+
"- You can ONLY schedule one callback per call. Choose deliberately.",
|
|
10799
|
+
"",
|
|
10800
|
+
"You can check the current status (time left, extensions used, callback availability) at any moment with get_call_status \u2014 but do not fixate; keep the call moving.",
|
|
10801
|
+
"",
|
|
10802
|
+
"When in doubt \u2014 out of time, out of extensions, the caller is uncertain \u2014 preferred order is: wrap up gracefully \u2192 schedule_callback \u2192 sign off. Never go silent or invent excuses; the right move is always a clean handoff to a future call."
|
|
10803
|
+
);
|
|
10804
|
+
}
|
|
10482
10805
|
return lines.join("\n");
|
|
10483
10806
|
}
|
|
10484
10807
|
function toolErrorText(err) {
|
|
@@ -10684,6 +11007,26 @@ ${task}`);
|
|
|
10684
11007
|
'# What you already know\nThe following is your own long-term memory \u2014 knowledge, preferences, and lessons you have accumulated over time. Treat it as your own experience and act on it naturally. Do not read it aloud or mention that it is "memory"; simply know it.\n\n' + memory
|
|
10685
11008
|
);
|
|
10686
11009
|
}
|
|
11010
|
+
const budget = opts.callBudget;
|
|
11011
|
+
if (budget && budget.seconds > 0) {
|
|
11012
|
+
const mins = Math.round(budget.seconds / 60);
|
|
11013
|
+
const human = mins >= 1 ? `about ${mins} minute(s)` : `${budget.seconds} seconds`;
|
|
11014
|
+
const tips = [];
|
|
11015
|
+
if (budget.extensionEnabled) {
|
|
11016
|
+
tips.push(
|
|
11017
|
+
"If you need more time, call extend_call_time({ seconds, reason }) BEFORE you run out. Auto-approved within the call's extension policy."
|
|
11018
|
+
);
|
|
11019
|
+
}
|
|
11020
|
+
if (budget.callbackEnabled) {
|
|
11021
|
+
tips.push(
|
|
11022
|
+
"If you cannot finish in time, the caller wants you to ring back later, or the conversation naturally pauses for a follow-up, call schedule_callback({ delay_seconds, reason, summary_for_next_call }) BEFORE signing off. The next call automatically picks up with your summary and the transcript so far."
|
|
11023
|
+
);
|
|
11024
|
+
}
|
|
11025
|
+
sections.push(
|
|
11026
|
+
`# Your time on this call
|
|
11027
|
+
You have ${human} for this call. The system will quietly remind you at the 2-minute and 30-second marks \u2014 pace the conversation so you can wrap up cleanly.` + (tips.length > 0 ? "\n\n" + tips.join("\n") : "")
|
|
11028
|
+
);
|
|
11029
|
+
}
|
|
10687
11030
|
const toolGuidance = opts.toolGuidance?.trim();
|
|
10688
11031
|
if (toolGuidance) {
|
|
10689
11032
|
sections.push(toolGuidance);
|
|
@@ -10723,6 +11066,10 @@ function buildRealtimeSessionConfig(opts) {
|
|
|
10723
11066
|
function buildOpenAIRealtimeUrl(model = DEFAULT_REALTIME_MODEL) {
|
|
10724
11067
|
return `${OPENAI_REALTIME_URL}?model=${encodeURIComponent(model || DEFAULT_REALTIME_MODEL)}`;
|
|
10725
11068
|
}
|
|
11069
|
+
var MAX_CALLBACK_SUMMARY_LENGTH = 1500;
|
|
11070
|
+
var MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH = 2500;
|
|
11071
|
+
var CALL_BUDGET_REMINDER_MARKS_SECONDS = [120, 30];
|
|
11072
|
+
var CALL_BUDGET_GRACE_SECONDS = 30;
|
|
10726
11073
|
var RealtimeVoiceBridge = class {
|
|
10727
11074
|
carrier;
|
|
10728
11075
|
openai;
|
|
@@ -10733,6 +11080,46 @@ var RealtimeVoiceBridge = class {
|
|
|
10733
11080
|
maxToolCallMs;
|
|
10734
11081
|
onTranscript;
|
|
10735
11082
|
onEnd;
|
|
11083
|
+
/** Injectable clock + timers (tests substitute fakes). */
|
|
11084
|
+
nowFn;
|
|
11085
|
+
setTimeoutFn;
|
|
11086
|
+
clearTimeoutFn;
|
|
11087
|
+
/** v0.9.81 — extension / callback state. */
|
|
11088
|
+
extensionPolicy;
|
|
11089
|
+
callbackPolicy;
|
|
11090
|
+
onCallbackScheduled;
|
|
11091
|
+
/** Initial soft budget, in seconds. 0 = no bridge-side timer (legacy). */
|
|
11092
|
+
initialBudgetSeconds;
|
|
11093
|
+
/** Wall-clock ms when the call started, set on first carrier hello. */
|
|
11094
|
+
callStartedAtMs = null;
|
|
11095
|
+
/** Wall-clock ms when the soft deadline fires. Bumped by extensions. */
|
|
11096
|
+
softDeadlineMs = null;
|
|
11097
|
+
/** Soft-end timer (fires once, then schedules the grace timer). */
|
|
11098
|
+
softEndTimer = null;
|
|
11099
|
+
/** Final hard-end timer that fires after the grace window. */
|
|
11100
|
+
graceEndTimer = null;
|
|
11101
|
+
/** Reminder timers for the T-N marks. Cleared/re-armed on extensions. */
|
|
11102
|
+
reminderTimers = [];
|
|
11103
|
+
/** Marks (in seconds-remaining) we've already fired this call. Dedup
|
|
11104
|
+
* prevents re-injecting the same reminder after an extension if the
|
|
11105
|
+
* new deadline still has us past the same mark. */
|
|
11106
|
+
firedReminderMarks = /* @__PURE__ */ new Set();
|
|
11107
|
+
/** Count of extensions granted this call. */
|
|
11108
|
+
extensionsUsed = 0;
|
|
11109
|
+
/** Total extra seconds granted across all extensions this call. */
|
|
11110
|
+
extensionSecondsUsed = 0;
|
|
11111
|
+
/** True once the agent's schedule_callback request was accepted. */
|
|
11112
|
+
callbackArmed = false;
|
|
11113
|
+
/** Captured for the API layer when the soft deadline fires. */
|
|
11114
|
+
endedByTimeBudgetFlag = false;
|
|
11115
|
+
/**
|
|
11116
|
+
* Sliding window of recent assistant + system utterances, used to
|
|
11117
|
+
* build the transcript digest carried into a scheduled callback.
|
|
11118
|
+
* Capped at {@link MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH} chars so
|
|
11119
|
+
* the digest itself can always be produced cheaply even on a long
|
|
11120
|
+
* call.
|
|
11121
|
+
*/
|
|
11122
|
+
recentUtterances = [];
|
|
10736
11123
|
/** Carrier `hello`/`start` received — the call leg is live. */
|
|
10737
11124
|
helloSeen = false;
|
|
10738
11125
|
/** OpenAI socket open + `session.update` sent. */
|
|
@@ -10790,6 +11177,13 @@ var RealtimeVoiceBridge = class {
|
|
|
10790
11177
|
this.maxToolCallMs = opts.maxToolCallMs ?? REALTIME_TOOL_CALL_TIMEOUT_MS;
|
|
10791
11178
|
this.onTranscript = opts.onTranscript;
|
|
10792
11179
|
this.onEnd = opts.onEnd;
|
|
11180
|
+
this.nowFn = opts.now ?? Date.now;
|
|
11181
|
+
this.setTimeoutFn = opts.setTimeoutFn ?? setTimeout;
|
|
11182
|
+
this.clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout;
|
|
11183
|
+
this.initialBudgetSeconds = opts.callBudgetSeconds && opts.callBudgetSeconds > 0 ? Math.floor(opts.callBudgetSeconds) : 0;
|
|
11184
|
+
this.extensionPolicy = opts.extensionPolicy;
|
|
11185
|
+
this.callbackPolicy = opts.callbackPolicy;
|
|
11186
|
+
this.onCallbackScheduled = opts.onCallbackScheduled;
|
|
10793
11187
|
}
|
|
10794
11188
|
/** True once the bridge has ended. */
|
|
10795
11189
|
get isEnded() {
|
|
@@ -10951,6 +11345,7 @@ var RealtimeVoiceBridge = class {
|
|
|
10951
11345
|
from: event.from,
|
|
10952
11346
|
to: event.to
|
|
10953
11347
|
});
|
|
11348
|
+
this.startCallBudget();
|
|
10954
11349
|
return;
|
|
10955
11350
|
}
|
|
10956
11351
|
if (event.kind === "audio") {
|
|
@@ -11029,14 +11424,20 @@ var RealtimeVoiceBridge = class {
|
|
|
11029
11424
|
case "response.output_audio_transcript.done":
|
|
11030
11425
|
case "response.audio_transcript.done": {
|
|
11031
11426
|
const text = this.assistantTranscript.trim();
|
|
11032
|
-
if (text)
|
|
11427
|
+
if (text) {
|
|
11428
|
+
this.emitTranscript("agent", text);
|
|
11429
|
+
this.noteUtterance(`Agent: ${text}`);
|
|
11430
|
+
}
|
|
11033
11431
|
this.assistantTranscript = "";
|
|
11034
11432
|
return;
|
|
11035
11433
|
}
|
|
11036
11434
|
// Caller speech transcription, when input transcription is on.
|
|
11037
11435
|
case "conversation.item.input_audio_transcription.completed": {
|
|
11038
11436
|
const text = typeof event.transcript === "string" ? event.transcript.trim() : "";
|
|
11039
|
-
if (text)
|
|
11437
|
+
if (text) {
|
|
11438
|
+
this.emitTranscript("provider", text, { speaker: "caller" });
|
|
11439
|
+
this.noteUtterance(`Caller: ${text}`);
|
|
11440
|
+
}
|
|
11040
11441
|
return;
|
|
11041
11442
|
}
|
|
11042
11443
|
// A new output item was added to the response. When it is a
|
|
@@ -11156,6 +11557,369 @@ var RealtimeVoiceBridge = class {
|
|
|
11156
11557
|
});
|
|
11157
11558
|
this.safeSend(this.openai, { type: "response.create" });
|
|
11158
11559
|
}
|
|
11560
|
+
// ─── Call-budget timers / extensions / callback (v0.9.81) ─────────
|
|
11561
|
+
/** True if the agent's `schedule_callback` request has been accepted. */
|
|
11562
|
+
get isCallbackArmed() {
|
|
11563
|
+
return this.callbackArmed;
|
|
11564
|
+
}
|
|
11565
|
+
/**
|
|
11566
|
+
* Seconds remaining on the current soft deadline, floored at 0. Returns
|
|
11567
|
+
* the initial budget if hello hasn't fired yet, and `Infinity` if no
|
|
11568
|
+
* call budget was configured (legacy mode). Used by `get_call_status`.
|
|
11569
|
+
*/
|
|
11570
|
+
getTimeRemainingSeconds() {
|
|
11571
|
+
if (this.initialBudgetSeconds <= 0) return Number.POSITIVE_INFINITY;
|
|
11572
|
+
if (this.callStartedAtMs == null || this.softDeadlineMs == null) {
|
|
11573
|
+
return this.initialBudgetSeconds;
|
|
11574
|
+
}
|
|
11575
|
+
return Math.max(0, Math.ceil((this.softDeadlineMs - this.nowFn()) / 1e3));
|
|
11576
|
+
}
|
|
11577
|
+
/**
|
|
11578
|
+
* Public extension state snapshot for `get_call_status`. Each value is
|
|
11579
|
+
* "what the agent has left" so the model can decide whether to call
|
|
11580
|
+
* `extend_call_time` at all — exposing both the per-request cap AND
|
|
11581
|
+
* the remaining budget makes greedy / unbounded extension attempts
|
|
11582
|
+
* impossible.
|
|
11583
|
+
*/
|
|
11584
|
+
getExtensionStatus() {
|
|
11585
|
+
const pol = this.extensionPolicy;
|
|
11586
|
+
if (!pol) {
|
|
11587
|
+
return {
|
|
11588
|
+
extensionsUsed: 0,
|
|
11589
|
+
extensionsRemaining: 0,
|
|
11590
|
+
secondsUsedSoFar: 0,
|
|
11591
|
+
secondsAvailable: 0,
|
|
11592
|
+
maxSecondsPerRequest: 0
|
|
11593
|
+
};
|
|
11594
|
+
}
|
|
11595
|
+
return {
|
|
11596
|
+
extensionsUsed: this.extensionsUsed,
|
|
11597
|
+
extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
|
|
11598
|
+
secondsUsedSoFar: this.extensionSecondsUsed,
|
|
11599
|
+
secondsAvailable: Math.max(0, pol.maxTotalExtensionSeconds - this.extensionSecondsUsed),
|
|
11600
|
+
maxSecondsPerRequest: pol.maxSecondsPerRequest
|
|
11601
|
+
};
|
|
11602
|
+
}
|
|
11603
|
+
/**
|
|
11604
|
+
* Grant (or refuse) more time on the call. Auto-approved within all
|
|
11605
|
+
* three policy caps; the granted amount is the min of:
|
|
11606
|
+
*
|
|
11607
|
+
* - the agent's requested seconds (positive integer, clamped > 0)
|
|
11608
|
+
* - policy.maxSecondsPerRequest
|
|
11609
|
+
* - policy.maxTotalExtensionSeconds − seconds already granted
|
|
11610
|
+
*
|
|
11611
|
+
* AND we won't push the new deadline past the hard ceiling
|
|
11612
|
+
* (PHONE_SERVER_MAX_CALL_DURATION_SECONDS from call start). The
|
|
11613
|
+
* returned shape always includes a model-readable `reason` so a
|
|
11614
|
+
* partial grant ("you asked for 5 min, you got 2 min") doesn't
|
|
11615
|
+
* confuse the agent.
|
|
11616
|
+
*
|
|
11617
|
+
* Failure modes (granted: 0):
|
|
11618
|
+
* - no extension policy on this call
|
|
11619
|
+
* - max requests already used
|
|
11620
|
+
* - max total seconds already used
|
|
11621
|
+
* - call already ended
|
|
11622
|
+
* - non-positive `seconds`
|
|
11623
|
+
*/
|
|
11624
|
+
extendCallTime(requestedSeconds, reason) {
|
|
11625
|
+
if (this.ended) {
|
|
11626
|
+
return {
|
|
11627
|
+
granted: false,
|
|
11628
|
+
secondsGranted: 0,
|
|
11629
|
+
secondsRemaining: 0,
|
|
11630
|
+
extensionsRemaining: 0,
|
|
11631
|
+
message: "The call has already ended; no more extensions can be granted."
|
|
11632
|
+
};
|
|
11633
|
+
}
|
|
11634
|
+
const pol = this.extensionPolicy;
|
|
11635
|
+
if (!pol) {
|
|
11636
|
+
return {
|
|
11637
|
+
granted: false,
|
|
11638
|
+
secondsGranted: 0,
|
|
11639
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
11640
|
+
extensionsRemaining: 0,
|
|
11641
|
+
message: "Extensions are not enabled for this call."
|
|
11642
|
+
};
|
|
11643
|
+
}
|
|
11644
|
+
if (this.initialBudgetSeconds <= 0 || this.softDeadlineMs == null || this.callStartedAtMs == null) {
|
|
11645
|
+
return {
|
|
11646
|
+
granted: false,
|
|
11647
|
+
secondsGranted: 0,
|
|
11648
|
+
secondsRemaining: Number.POSITIVE_INFINITY,
|
|
11649
|
+
extensionsRemaining: pol.maxRequestsPerCall - this.extensionsUsed,
|
|
11650
|
+
message: "This call has no soft time budget, so extensions are a no-op."
|
|
11651
|
+
};
|
|
11652
|
+
}
|
|
11653
|
+
if (this.extensionsUsed >= pol.maxRequestsPerCall) {
|
|
11654
|
+
return {
|
|
11655
|
+
granted: false,
|
|
11656
|
+
secondsGranted: 0,
|
|
11657
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
11658
|
+
extensionsRemaining: 0,
|
|
11659
|
+
message: `Out of extensions \u2014 already used ${pol.maxRequestsPerCall}/${pol.maxRequestsPerCall} this call. Wrap up or schedule a callback.`
|
|
11660
|
+
};
|
|
11661
|
+
}
|
|
11662
|
+
const remainingBudgetSeconds = Math.max(0, pol.maxTotalExtensionSeconds - this.extensionSecondsUsed);
|
|
11663
|
+
if (remainingBudgetSeconds <= 0) {
|
|
11664
|
+
return {
|
|
11665
|
+
granted: false,
|
|
11666
|
+
secondsGranted: 0,
|
|
11667
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
11668
|
+
extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
|
|
11669
|
+
message: `Out of extension time \u2014 already granted ${this.extensionSecondsUsed}s of the ${pol.maxTotalExtensionSeconds}s cap.`
|
|
11670
|
+
};
|
|
11671
|
+
}
|
|
11672
|
+
const asked = Math.floor(requestedSeconds);
|
|
11673
|
+
if (!Number.isFinite(asked) || asked <= 0) {
|
|
11674
|
+
return {
|
|
11675
|
+
granted: false,
|
|
11676
|
+
secondsGranted: 0,
|
|
11677
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
11678
|
+
extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
|
|
11679
|
+
message: "extend_call_time requires a positive integer number of seconds."
|
|
11680
|
+
};
|
|
11681
|
+
}
|
|
11682
|
+
let granted = Math.min(asked, pol.maxSecondsPerRequest, remainingBudgetSeconds);
|
|
11683
|
+
const elapsedSeconds = Math.floor((this.nowFn() - this.callStartedAtMs) / 1e3);
|
|
11684
|
+
const maxAllowedFromStart = 3600;
|
|
11685
|
+
const hardCeilingRoom = Math.max(0, maxAllowedFromStart - (elapsedSeconds + this.getTimeRemainingSeconds()));
|
|
11686
|
+
granted = Math.min(granted, hardCeilingRoom);
|
|
11687
|
+
if (granted <= 0) {
|
|
11688
|
+
return {
|
|
11689
|
+
granted: false,
|
|
11690
|
+
secondsGranted: 0,
|
|
11691
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
11692
|
+
extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
|
|
11693
|
+
message: "No extension granted \u2014 the call is already at the hard duration ceiling."
|
|
11694
|
+
};
|
|
11695
|
+
}
|
|
11696
|
+
this.extensionsUsed += 1;
|
|
11697
|
+
this.extensionSecondsUsed += granted;
|
|
11698
|
+
this.softDeadlineMs = this.softDeadlineMs + granted * 1e3;
|
|
11699
|
+
this.rearmBudgetTimers();
|
|
11700
|
+
this.emitTranscript("system", `Granted ${granted}s extension (#${this.extensionsUsed}). Reason: ${truncate2(asString5(reason) || "unspecified", 120)}`, {
|
|
11701
|
+
extensionsUsed: this.extensionsUsed,
|
|
11702
|
+
extensionSecondsUsed: this.extensionSecondsUsed,
|
|
11703
|
+
softDeadlineMs: this.softDeadlineMs
|
|
11704
|
+
});
|
|
11705
|
+
return {
|
|
11706
|
+
granted: true,
|
|
11707
|
+
secondsGranted: granted,
|
|
11708
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
11709
|
+
extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
|
|
11710
|
+
message: `Granted ${granted} more seconds (${this.getTimeRemainingSeconds()}s now remaining). ${Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed)} extension(s) left after this one.`
|
|
11711
|
+
};
|
|
11712
|
+
}
|
|
11713
|
+
/**
|
|
11714
|
+
* Capture the agent's `schedule_callback` request. The bridge VALIDATES
|
|
11715
|
+
* (policy allows it, delay is in the legal window, summary present),
|
|
11716
|
+
* builds a transcript digest from {@link recentUtterances}, then fires
|
|
11717
|
+
* {@link onCallbackScheduled} so the API layer can persist + arm the
|
|
11718
|
+
* scheduler. The bridge does NOT itself dial — that's the scheduler's
|
|
11719
|
+
* job, fired at the requested wall-clock time.
|
|
11720
|
+
*
|
|
11721
|
+
* Returning `{ accepted: true }` arms `isCallbackArmed`, which the
|
|
11722
|
+
* end-of-call path uses to skip the legacy operator-query callback
|
|
11723
|
+
* flag (the agent has already declared its own follow-up plan).
|
|
11724
|
+
*/
|
|
11725
|
+
scheduleCallback(req) {
|
|
11726
|
+
if (this.ended) {
|
|
11727
|
+
return { accepted: false, message: "Cannot schedule a callback \u2014 the call has already ended." };
|
|
11728
|
+
}
|
|
11729
|
+
if (this.callbackArmed) {
|
|
11730
|
+
return { accepted: false, message: "A callback has already been scheduled for this call. Stick with that plan." };
|
|
11731
|
+
}
|
|
11732
|
+
const pol = this.callbackPolicy;
|
|
11733
|
+
if (!pol || !pol.allowAutoCallback || pol.maxCallbackChain <= 0) {
|
|
11734
|
+
return {
|
|
11735
|
+
accepted: false,
|
|
11736
|
+
message: "Auto-callbacks are disabled by policy on this call. Tell the caller you will follow up another way."
|
|
11737
|
+
};
|
|
11738
|
+
}
|
|
11739
|
+
const delay = Math.floor(req.delaySeconds);
|
|
11740
|
+
const minDelay = 30;
|
|
11741
|
+
const maxDelay = 7 * 24 * 60 * 60;
|
|
11742
|
+
if (!Number.isFinite(delay) || delay < minDelay) {
|
|
11743
|
+
return {
|
|
11744
|
+
accepted: false,
|
|
11745
|
+
message: `Callbacks must be scheduled at least ${minDelay}s in the future.`
|
|
11746
|
+
};
|
|
11747
|
+
}
|
|
11748
|
+
if (delay > maxDelay) {
|
|
11749
|
+
return {
|
|
11750
|
+
accepted: false,
|
|
11751
|
+
message: `Callbacks must be scheduled within ${Math.floor(maxDelay / 86400)} days.`
|
|
11752
|
+
};
|
|
11753
|
+
}
|
|
11754
|
+
const summary = asString5(req.summary);
|
|
11755
|
+
if (!summary) {
|
|
11756
|
+
return {
|
|
11757
|
+
accepted: false,
|
|
11758
|
+
message: "schedule_callback requires a non-empty `summary` for the next call to pick up from."
|
|
11759
|
+
};
|
|
11760
|
+
}
|
|
11761
|
+
const at = new Date(this.nowFn() + delay * 1e3).toISOString();
|
|
11762
|
+
const digest = this.composeTranscriptDigest();
|
|
11763
|
+
const payload = {
|
|
11764
|
+
at,
|
|
11765
|
+
reason: truncate2(asString5(req.reason) || "no reason given", 240),
|
|
11766
|
+
agentSummary: truncate2(summary, MAX_CALLBACK_SUMMARY_LENGTH),
|
|
11767
|
+
transcriptDigest: digest
|
|
11768
|
+
};
|
|
11769
|
+
try {
|
|
11770
|
+
this.onCallbackScheduled?.(payload);
|
|
11771
|
+
} catch (err) {
|
|
11772
|
+
return { accepted: false, message: `Could not arm callback: ${errorText(err)}` };
|
|
11773
|
+
}
|
|
11774
|
+
this.callbackArmed = true;
|
|
11775
|
+
this.emitTranscript("system", `Callback scheduled for ${at}. Reason: ${payload.reason}`, {
|
|
11776
|
+
scheduledAt: at,
|
|
11777
|
+
summaryLength: payload.agentSummary.length,
|
|
11778
|
+
digestLength: digest.length
|
|
11779
|
+
});
|
|
11780
|
+
return {
|
|
11781
|
+
accepted: true,
|
|
11782
|
+
at,
|
|
11783
|
+
message: `Callback scheduled for ${at}. The next call will pick up with your summary + the transcript so far.`
|
|
11784
|
+
};
|
|
11785
|
+
}
|
|
11786
|
+
/**
|
|
11787
|
+
* Public time-budget snapshot for `get_call_status`. Bundles
|
|
11788
|
+
* everything the agent needs to decide whether to keep going, ask
|
|
11789
|
+
* for more time, or schedule a callback.
|
|
11790
|
+
*/
|
|
11791
|
+
getCallStatus() {
|
|
11792
|
+
return {
|
|
11793
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
11794
|
+
softDeadlineAt: this.softDeadlineMs ? new Date(this.softDeadlineMs).toISOString() : null,
|
|
11795
|
+
extension: this.getExtensionStatus(),
|
|
11796
|
+
callbackAvailable: !!this.callbackPolicy?.allowAutoCallback && (this.callbackPolicy?.maxCallbackChain ?? 0) > 0 && !this.callbackArmed,
|
|
11797
|
+
callbackArmed: this.callbackArmed
|
|
11798
|
+
};
|
|
11799
|
+
}
|
|
11800
|
+
/**
|
|
11801
|
+
* Arm the soft-deadline timer + reminder timers. Called once at hello.
|
|
11802
|
+
* No-op when the bridge has no budget configured.
|
|
11803
|
+
*/
|
|
11804
|
+
startCallBudget() {
|
|
11805
|
+
if (this.initialBudgetSeconds <= 0) return;
|
|
11806
|
+
const nowMs = this.nowFn();
|
|
11807
|
+
this.callStartedAtMs = nowMs;
|
|
11808
|
+
this.softDeadlineMs = nowMs + this.initialBudgetSeconds * 1e3;
|
|
11809
|
+
this.emitTranscript("system", `Call budget armed: ${this.initialBudgetSeconds}s, soft deadline ${new Date(this.softDeadlineMs).toISOString()}.`, {
|
|
11810
|
+
budgetSeconds: this.initialBudgetSeconds
|
|
11811
|
+
});
|
|
11812
|
+
this.rearmBudgetTimers();
|
|
11813
|
+
}
|
|
11814
|
+
/**
|
|
11815
|
+
* Cancel all existing budget timers and re-arm them against
|
|
11816
|
+
* {@link softDeadlineMs}. Called at startCallBudget time AND after
|
|
11817
|
+
* every successful {@link extendCallTime} grant — the timers always
|
|
11818
|
+
* reflect the CURRENT deadline.
|
|
11819
|
+
*/
|
|
11820
|
+
rearmBudgetTimers() {
|
|
11821
|
+
this.clearBudgetTimers();
|
|
11822
|
+
if (this.softDeadlineMs == null) return;
|
|
11823
|
+
const nowMs = this.nowFn();
|
|
11824
|
+
const msToDeadline = this.softDeadlineMs - nowMs;
|
|
11825
|
+
for (const mark of CALL_BUDGET_REMINDER_MARKS_SECONDS) {
|
|
11826
|
+
const msUntilMark = msToDeadline - mark * 1e3;
|
|
11827
|
+
if (msUntilMark <= 0) continue;
|
|
11828
|
+
if (this.firedReminderMarks.has(mark)) continue;
|
|
11829
|
+
const t = this.setTimeoutFn(() => {
|
|
11830
|
+
this.firedReminderMarks.add(mark);
|
|
11831
|
+
this.injectReminder(mark);
|
|
11832
|
+
}, msUntilMark);
|
|
11833
|
+
this.reminderTimers.push(t);
|
|
11834
|
+
}
|
|
11835
|
+
const msUntilSoftEnd = Math.max(0, msToDeadline);
|
|
11836
|
+
this.softEndTimer = this.setTimeoutFn(() => {
|
|
11837
|
+
this.softEndTimer = null;
|
|
11838
|
+
this.onSoftDeadline();
|
|
11839
|
+
}, msUntilSoftEnd);
|
|
11840
|
+
}
|
|
11841
|
+
/** Cancel all currently-armed budget timers. Idempotent. */
|
|
11842
|
+
clearBudgetTimers() {
|
|
11843
|
+
if (this.softEndTimer) {
|
|
11844
|
+
this.clearTimeoutFn(this.softEndTimer);
|
|
11845
|
+
this.softEndTimer = null;
|
|
11846
|
+
}
|
|
11847
|
+
if (this.graceEndTimer) {
|
|
11848
|
+
this.clearTimeoutFn(this.graceEndTimer);
|
|
11849
|
+
this.graceEndTimer = null;
|
|
11850
|
+
}
|
|
11851
|
+
for (const t of this.reminderTimers) {
|
|
11852
|
+
this.clearTimeoutFn(t);
|
|
11853
|
+
}
|
|
11854
|
+
this.reminderTimers = [];
|
|
11855
|
+
}
|
|
11856
|
+
/**
|
|
11857
|
+
* Inject a "you have ~N seconds left" system message into the live
|
|
11858
|
+
* OpenAI session. The model receives it as a `conversation.item.create`
|
|
11859
|
+
* with role:`system`, followed by `response.create` so it can decide
|
|
11860
|
+
* whether to acknowledge it out loud (often it just naturally
|
|
11861
|
+
* accelerates wrap-up; we don't force a verbal "I have 30 seconds").
|
|
11862
|
+
*/
|
|
11863
|
+
injectReminder(secondsRemaining) {
|
|
11864
|
+
if (this.ended || !this.openaiReady) return;
|
|
11865
|
+
const text = secondsRemaining >= 60 ? `[system] About ${Math.round(secondsRemaining / 60)} minute(s) left on this call. Start wrapping up \u2014 if you need more time, call extend_call_time; if you can't finish, call schedule_callback before signing off.` : `[system] About ${secondsRemaining}s left on this call. Wrap up now: thank the caller, give a clear next step, and sign off.`;
|
|
11866
|
+
this.safeSend(this.openai, {
|
|
11867
|
+
type: "conversation.item.create",
|
|
11868
|
+
item: { type: "message", role: "system", content: [{ type: "input_text", text }] }
|
|
11869
|
+
});
|
|
11870
|
+
this.safeSend(this.openai, { type: "response.create" });
|
|
11871
|
+
this.emitTranscript("system", `Time reminder injected at T-${secondsRemaining}s.`);
|
|
11872
|
+
}
|
|
11873
|
+
/**
|
|
11874
|
+
* Fires once the soft deadline elapses. Injects a "your time is up"
|
|
11875
|
+
* system message + schedules the grace-window hard end. If the agent
|
|
11876
|
+
* uses the grace window to call `schedule_callback` or `extend_call_time`
|
|
11877
|
+
* the latter can push the deadline forward again — that's fine, the
|
|
11878
|
+
* grace timer is cancelled by {@link extendCallTime} via rearmBudgetTimers.
|
|
11879
|
+
*/
|
|
11880
|
+
onSoftDeadline() {
|
|
11881
|
+
if (this.ended) return;
|
|
11882
|
+
if (this.openaiReady) {
|
|
11883
|
+
const text = `[system] Your time on this call is up. You have ~${CALL_BUDGET_GRACE_SECONDS} seconds to wrap up. If you need to continue with this person, call schedule_callback NOW with the time and a summary. Then thank them, give a clear next step, and sign off. The line will close at the end of the grace window.`;
|
|
11884
|
+
this.safeSend(this.openai, {
|
|
11885
|
+
type: "conversation.item.create",
|
|
11886
|
+
item: { type: "message", role: "system", content: [{ type: "input_text", text }] }
|
|
11887
|
+
});
|
|
11888
|
+
this.safeSend(this.openai, { type: "response.create" });
|
|
11889
|
+
}
|
|
11890
|
+
this.emitTranscript("system", `Soft deadline reached; ${CALL_BUDGET_GRACE_SECONDS}s grace window started.`);
|
|
11891
|
+
this.endedByTimeBudgetFlag = true;
|
|
11892
|
+
this.graceEndTimer = this.setTimeoutFn(() => {
|
|
11893
|
+
this.graceEndTimer = null;
|
|
11894
|
+
if (this.ended) return;
|
|
11895
|
+
this.emitTranscript("system", "Grace window elapsed \u2014 bridge ending call.");
|
|
11896
|
+
this.end("time-budget-exceeded");
|
|
11897
|
+
}, CALL_BUDGET_GRACE_SECONDS * 1e3);
|
|
11898
|
+
}
|
|
11899
|
+
/**
|
|
11900
|
+
* Add an utterance to the rolling buffer used for the callback
|
|
11901
|
+
* transcript digest. Bounded by char count, not entry count, so a
|
|
11902
|
+
* burst of short turns doesn't get pruned prematurely.
|
|
11903
|
+
*/
|
|
11904
|
+
noteUtterance(line) {
|
|
11905
|
+
if (!line) return;
|
|
11906
|
+
this.recentUtterances.push(line);
|
|
11907
|
+
let total = this.recentUtterances.reduce((n, s) => n + s.length, 0);
|
|
11908
|
+
while (total > MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH * 2 && this.recentUtterances.length > 1) {
|
|
11909
|
+
const dropped = this.recentUtterances.shift();
|
|
11910
|
+
total -= dropped.length;
|
|
11911
|
+
}
|
|
11912
|
+
}
|
|
11913
|
+
/**
|
|
11914
|
+
* Compose a transcript digest from the rolling buffer. Used as the
|
|
11915
|
+
* "context from the previous call" payload in {@link scheduleCallback}.
|
|
11916
|
+
* Always honours {@link MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH}.
|
|
11917
|
+
*/
|
|
11918
|
+
composeTranscriptDigest() {
|
|
11919
|
+
const joined = this.recentUtterances.join("\n");
|
|
11920
|
+
if (joined.length <= MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH) return joined;
|
|
11921
|
+
return "\u2026\n" + joined.slice(joined.length - MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH + 2);
|
|
11922
|
+
}
|
|
11159
11923
|
// ─── Teardown ─────────────────────────────────────────
|
|
11160
11924
|
/**
|
|
11161
11925
|
* End the bridge. Idempotent — the first call wins, later calls are
|
|
@@ -11165,6 +11929,7 @@ var RealtimeVoiceBridge = class {
|
|
|
11165
11929
|
end(reason) {
|
|
11166
11930
|
if (this.ended) return;
|
|
11167
11931
|
this.ended = true;
|
|
11932
|
+
this.clearBudgetTimers();
|
|
11168
11933
|
if (this.droppedFrames > 0) {
|
|
11169
11934
|
this.onTranscript?.({
|
|
11170
11935
|
source: "system",
|
|
@@ -11193,7 +11958,11 @@ var RealtimeVoiceBridge = class {
|
|
|
11193
11958
|
this.openai.close();
|
|
11194
11959
|
} catch {
|
|
11195
11960
|
}
|
|
11196
|
-
this.
|
|
11961
|
+
if (this.endedByTimeBudgetFlag) {
|
|
11962
|
+
this.onEnd?.({ reason, pendingToolCalls, endedByTimeBudget: true });
|
|
11963
|
+
} else {
|
|
11964
|
+
this.onEnd?.({ reason, pendingToolCalls });
|
|
11965
|
+
}
|
|
11197
11966
|
}
|
|
11198
11967
|
// ─── Internals ────────────────────────────────────────
|
|
11199
11968
|
noteDroppedFrame() {
|