@agenticmail/core 0.9.26 → 0.9.28
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 +834 -6
- package/dist/index.d.cts +474 -161
- package/dist/index.d.ts +474 -161
- package/dist/index.js +834 -6
- package/package.json +1 -1
package/dist/index.cjs
CHANGED
|
@@ -7514,10 +7514,26 @@ var PHONE_MISSION_STATES = [
|
|
|
7514
7514
|
"failed",
|
|
7515
7515
|
"cancelled"
|
|
7516
7516
|
];
|
|
7517
|
-
var PHONE_SERVER_MAX_CALL_DURATION_SECONDS =
|
|
7517
|
+
var PHONE_SERVER_MAX_CALL_DURATION_SECONDS = 7200;
|
|
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 = 900;
|
|
7522
|
+
var PHONE_SERVER_MAX_EXTENSION_REQUESTS_PER_CALL = 8;
|
|
7523
|
+
var PHONE_SERVER_MAX_TOTAL_EXTENSION_SECONDS = 3600;
|
|
7524
|
+
var DEFAULT_EXTENSION_POLICY = {
|
|
7525
|
+
maxSecondsPerRequest: 300,
|
|
7526
|
+
// 5 minutes
|
|
7527
|
+
maxRequestsPerCall: 4,
|
|
7528
|
+
maxTotalExtensionSeconds: 1200
|
|
7529
|
+
// 20 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 {
|
|
@@ -10444,6 +10694,71 @@ var LOAD_SKILL_TOOL = {
|
|
|
10444
10694
|
additionalProperties: false
|
|
10445
10695
|
}
|
|
10446
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
|
+
};
|
|
10748
|
+
var END_CALL_TOOL = {
|
|
10749
|
+
type: "function",
|
|
10750
|
+
name: "end_call",
|
|
10751
|
+
description: `Hang up the call. You MUST call this AFTER you have said goodbye and the conversation is complete \u2014 saying "I'll hang up now" is not enough on its own, the call stays open until you actually call this tool. Use it when: the task is done and you have signed off, the caller has indicated the conversation is over, you have scheduled a callback and said goodbye, or the caller has stopped responding for an extended period. Once called the call drops immediately; you cannot un-hang-up.`,
|
|
10752
|
+
parameters: {
|
|
10753
|
+
type: "object",
|
|
10754
|
+
properties: {
|
|
10755
|
+
reason: {
|
|
10756
|
+
type: "string",
|
|
10757
|
+
description: 'One short line for the audit trail \u2014 e.g. "task complete", "caller said bye", "scheduled callback, signing off". Optional but recommended.'
|
|
10758
|
+
}
|
|
10759
|
+
}
|
|
10760
|
+
}
|
|
10761
|
+
};
|
|
10447
10762
|
var REALTIME_TOOL_DEFINITIONS = {
|
|
10448
10763
|
ask_operator: ASK_OPERATOR_TOOL,
|
|
10449
10764
|
web_search: WEB_SEARCH_TOOL,
|
|
@@ -10451,7 +10766,11 @@ var REALTIME_TOOL_DEFINITIONS = {
|
|
|
10451
10766
|
get_datetime: GET_DATETIME_TOOL,
|
|
10452
10767
|
search_email: SEARCH_EMAIL_TOOL,
|
|
10453
10768
|
search_skills: SEARCH_SKILLS_TOOL,
|
|
10454
|
-
load_skill: LOAD_SKILL_TOOL
|
|
10769
|
+
load_skill: LOAD_SKILL_TOOL,
|
|
10770
|
+
get_call_status: GET_CALL_STATUS_TOOL,
|
|
10771
|
+
extend_call_time: EXTEND_CALL_TIME_TOOL,
|
|
10772
|
+
schedule_callback: SCHEDULE_CALLBACK_TOOL,
|
|
10773
|
+
end_call: END_CALL_TOOL
|
|
10455
10774
|
};
|
|
10456
10775
|
function buildRealtimeToolGuidance(tools) {
|
|
10457
10776
|
if (tools.length === 0) return "";
|
|
@@ -10480,6 +10799,42 @@ function buildRealtimeToolGuidance(tools) {
|
|
|
10480
10799
|
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.`
|
|
10481
10800
|
);
|
|
10482
10801
|
}
|
|
10802
|
+
if (names.has("get_call_status") || names.has("extend_call_time") || names.has("schedule_callback")) {
|
|
10803
|
+
lines.push(
|
|
10804
|
+
"# Managing your time on this call",
|
|
10805
|
+
"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.",
|
|
10806
|
+
"",
|
|
10807
|
+
"If you need more time to finish the job:",
|
|
10808
|
+
`- 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.`,
|
|
10809
|
+
"",
|
|
10810
|
+
"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:",
|
|
10811
|
+
`- 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.`,
|
|
10812
|
+
`- 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.`,
|
|
10813
|
+
"- You can ONLY schedule one callback per call. Choose deliberately.",
|
|
10814
|
+
"",
|
|
10815
|
+
"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.",
|
|
10816
|
+
"",
|
|
10817
|
+
"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."
|
|
10818
|
+
);
|
|
10819
|
+
}
|
|
10820
|
+
if (names.has("end_call")) {
|
|
10821
|
+
lines.push(
|
|
10822
|
+
"# Hanging up \u2014 you must call end_call to actually drop the line",
|
|
10823
|
+
`SAYING "I'll hang up now" or "goodbye" does NOT drop the call \u2014 the line stays open until you call the end_call tool. This is the single most important habit on a real phone call: after you have said your goodbye sentence, IMMEDIATELY call end_call({ reason: "..." }). Do not wait for the caller to hang up first; do not assume the system will close the line for you. Call end_call yourself.`,
|
|
10824
|
+
"",
|
|
10825
|
+
"When to call end_call:",
|
|
10826
|
+
"- The task is complete and you have just said goodbye.",
|
|
10827
|
+
'- The caller said "thanks, bye" or otherwise signalled they are done.',
|
|
10828
|
+
`- You scheduled a callback and said "I'll call you back at <when>".`,
|
|
10829
|
+
"- The caller has gone silent for an extended stretch and is clearly not coming back.",
|
|
10830
|
+
"- The operator told you to hang up.",
|
|
10831
|
+
"",
|
|
10832
|
+
"Do NOT call end_call:",
|
|
10833
|
+
"- Mid-conversation when there is still pending business.",
|
|
10834
|
+
"- Because you ran into a tool error \u2014 handle it and keep the call going.",
|
|
10835
|
+
"- During a hold while a tool is running \u2014 let the tool finish."
|
|
10836
|
+
);
|
|
10837
|
+
}
|
|
10483
10838
|
return lines.join("\n");
|
|
10484
10839
|
}
|
|
10485
10840
|
function toolErrorText(err) {
|
|
@@ -10685,6 +11040,26 @@ ${task}`);
|
|
|
10685
11040
|
'# 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
|
|
10686
11041
|
);
|
|
10687
11042
|
}
|
|
11043
|
+
const budget = opts.callBudget;
|
|
11044
|
+
if (budget && budget.seconds > 0) {
|
|
11045
|
+
const mins = Math.round(budget.seconds / 60);
|
|
11046
|
+
const human = mins >= 1 ? `about ${mins} minute(s)` : `${budget.seconds} seconds`;
|
|
11047
|
+
const tips = [];
|
|
11048
|
+
if (budget.extensionEnabled) {
|
|
11049
|
+
tips.push(
|
|
11050
|
+
"If you need more time, call extend_call_time({ seconds, reason }) BEFORE you run out. Auto-approved within the call's extension policy."
|
|
11051
|
+
);
|
|
11052
|
+
}
|
|
11053
|
+
if (budget.callbackEnabled) {
|
|
11054
|
+
tips.push(
|
|
11055
|
+
"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."
|
|
11056
|
+
);
|
|
11057
|
+
}
|
|
11058
|
+
sections.push(
|
|
11059
|
+
`# Your time on this call
|
|
11060
|
+
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") : "")
|
|
11061
|
+
);
|
|
11062
|
+
}
|
|
10688
11063
|
const toolGuidance = opts.toolGuidance?.trim();
|
|
10689
11064
|
if (toolGuidance) {
|
|
10690
11065
|
sections.push(toolGuidance);
|
|
@@ -10724,6 +11099,10 @@ function buildRealtimeSessionConfig(opts) {
|
|
|
10724
11099
|
function buildOpenAIRealtimeUrl(model = DEFAULT_REALTIME_MODEL) {
|
|
10725
11100
|
return `${OPENAI_REALTIME_URL}?model=${encodeURIComponent(model || DEFAULT_REALTIME_MODEL)}`;
|
|
10726
11101
|
}
|
|
11102
|
+
var MAX_CALLBACK_SUMMARY_LENGTH = 1500;
|
|
11103
|
+
var MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH = 2500;
|
|
11104
|
+
var CALL_BUDGET_REMINDER_MARKS_SECONDS = [120, 30];
|
|
11105
|
+
var CALL_BUDGET_GRACE_SECONDS = 30;
|
|
10727
11106
|
var RealtimeVoiceBridge = class {
|
|
10728
11107
|
carrier;
|
|
10729
11108
|
openai;
|
|
@@ -10734,6 +11113,46 @@ var RealtimeVoiceBridge = class {
|
|
|
10734
11113
|
maxToolCallMs;
|
|
10735
11114
|
onTranscript;
|
|
10736
11115
|
onEnd;
|
|
11116
|
+
/** Injectable clock + timers (tests substitute fakes). */
|
|
11117
|
+
nowFn;
|
|
11118
|
+
setTimeoutFn;
|
|
11119
|
+
clearTimeoutFn;
|
|
11120
|
+
/** v0.9.81 — extension / callback state. */
|
|
11121
|
+
extensionPolicy;
|
|
11122
|
+
callbackPolicy;
|
|
11123
|
+
onCallbackScheduled;
|
|
11124
|
+
/** Initial soft budget, in seconds. 0 = no bridge-side timer (legacy). */
|
|
11125
|
+
initialBudgetSeconds;
|
|
11126
|
+
/** Wall-clock ms when the call started, set on first carrier hello. */
|
|
11127
|
+
callStartedAtMs = null;
|
|
11128
|
+
/** Wall-clock ms when the soft deadline fires. Bumped by extensions. */
|
|
11129
|
+
softDeadlineMs = null;
|
|
11130
|
+
/** Soft-end timer (fires once, then schedules the grace timer). */
|
|
11131
|
+
softEndTimer = null;
|
|
11132
|
+
/** Final hard-end timer that fires after the grace window. */
|
|
11133
|
+
graceEndTimer = null;
|
|
11134
|
+
/** Reminder timers for the T-N marks. Cleared/re-armed on extensions. */
|
|
11135
|
+
reminderTimers = [];
|
|
11136
|
+
/** Marks (in seconds-remaining) we've already fired this call. Dedup
|
|
11137
|
+
* prevents re-injecting the same reminder after an extension if the
|
|
11138
|
+
* new deadline still has us past the same mark. */
|
|
11139
|
+
firedReminderMarks = /* @__PURE__ */ new Set();
|
|
11140
|
+
/** Count of extensions granted this call. */
|
|
11141
|
+
extensionsUsed = 0;
|
|
11142
|
+
/** Total extra seconds granted across all extensions this call. */
|
|
11143
|
+
extensionSecondsUsed = 0;
|
|
11144
|
+
/** True once the agent's schedule_callback request was accepted. */
|
|
11145
|
+
callbackArmed = false;
|
|
11146
|
+
/** Captured for the API layer when the soft deadline fires. */
|
|
11147
|
+
endedByTimeBudgetFlag = false;
|
|
11148
|
+
/**
|
|
11149
|
+
* Sliding window of recent assistant + system utterances, used to
|
|
11150
|
+
* build the transcript digest carried into a scheduled callback.
|
|
11151
|
+
* Capped at {@link MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH} chars so
|
|
11152
|
+
* the digest itself can always be produced cheaply even on a long
|
|
11153
|
+
* call.
|
|
11154
|
+
*/
|
|
11155
|
+
recentUtterances = [];
|
|
10737
11156
|
/** Carrier `hello`/`start` received — the call leg is live. */
|
|
10738
11157
|
helloSeen = false;
|
|
10739
11158
|
/** OpenAI socket open + `session.update` sent. */
|
|
@@ -10791,6 +11210,13 @@ var RealtimeVoiceBridge = class {
|
|
|
10791
11210
|
this.maxToolCallMs = opts.maxToolCallMs ?? REALTIME_TOOL_CALL_TIMEOUT_MS;
|
|
10792
11211
|
this.onTranscript = opts.onTranscript;
|
|
10793
11212
|
this.onEnd = opts.onEnd;
|
|
11213
|
+
this.nowFn = opts.now ?? Date.now;
|
|
11214
|
+
this.setTimeoutFn = opts.setTimeoutFn ?? setTimeout;
|
|
11215
|
+
this.clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout;
|
|
11216
|
+
this.initialBudgetSeconds = opts.callBudgetSeconds && opts.callBudgetSeconds > 0 ? Math.floor(opts.callBudgetSeconds) : 0;
|
|
11217
|
+
this.extensionPolicy = opts.extensionPolicy;
|
|
11218
|
+
this.callbackPolicy = opts.callbackPolicy;
|
|
11219
|
+
this.onCallbackScheduled = opts.onCallbackScheduled;
|
|
10794
11220
|
}
|
|
10795
11221
|
/** True once the bridge has ended. */
|
|
10796
11222
|
get isEnded() {
|
|
@@ -10952,6 +11378,7 @@ var RealtimeVoiceBridge = class {
|
|
|
10952
11378
|
from: event.from,
|
|
10953
11379
|
to: event.to
|
|
10954
11380
|
});
|
|
11381
|
+
this.startCallBudget();
|
|
10955
11382
|
return;
|
|
10956
11383
|
}
|
|
10957
11384
|
if (event.kind === "audio") {
|
|
@@ -11030,14 +11457,20 @@ var RealtimeVoiceBridge = class {
|
|
|
11030
11457
|
case "response.output_audio_transcript.done":
|
|
11031
11458
|
case "response.audio_transcript.done": {
|
|
11032
11459
|
const text = this.assistantTranscript.trim();
|
|
11033
|
-
if (text)
|
|
11460
|
+
if (text) {
|
|
11461
|
+
this.emitTranscript("agent", text);
|
|
11462
|
+
this.noteUtterance(`Agent: ${text}`);
|
|
11463
|
+
}
|
|
11034
11464
|
this.assistantTranscript = "";
|
|
11035
11465
|
return;
|
|
11036
11466
|
}
|
|
11037
11467
|
// Caller speech transcription, when input transcription is on.
|
|
11038
11468
|
case "conversation.item.input_audio_transcription.completed": {
|
|
11039
11469
|
const text = typeof event.transcript === "string" ? event.transcript.trim() : "";
|
|
11040
|
-
if (text)
|
|
11470
|
+
if (text) {
|
|
11471
|
+
this.emitTranscript("provider", text, { speaker: "caller" });
|
|
11472
|
+
this.noteUtterance(`Caller: ${text}`);
|
|
11473
|
+
}
|
|
11041
11474
|
return;
|
|
11042
11475
|
}
|
|
11043
11476
|
// A new output item was added to the response. When it is a
|
|
@@ -11157,7 +11590,397 @@ var RealtimeVoiceBridge = class {
|
|
|
11157
11590
|
});
|
|
11158
11591
|
this.safeSend(this.openai, { type: "response.create" });
|
|
11159
11592
|
}
|
|
11593
|
+
// ─── Call-budget timers / extensions / callback (v0.9.81) ─────────
|
|
11594
|
+
/** True if the agent's `schedule_callback` request has been accepted. */
|
|
11595
|
+
get isCallbackArmed() {
|
|
11596
|
+
return this.callbackArmed;
|
|
11597
|
+
}
|
|
11598
|
+
/**
|
|
11599
|
+
* Seconds remaining on the current soft deadline, floored at 0. Returns
|
|
11600
|
+
* the initial budget if hello hasn't fired yet, and `Infinity` if no
|
|
11601
|
+
* call budget was configured (legacy mode). Used by `get_call_status`.
|
|
11602
|
+
*/
|
|
11603
|
+
getTimeRemainingSeconds() {
|
|
11604
|
+
if (this.initialBudgetSeconds <= 0) return Number.POSITIVE_INFINITY;
|
|
11605
|
+
if (this.callStartedAtMs == null || this.softDeadlineMs == null) {
|
|
11606
|
+
return this.initialBudgetSeconds;
|
|
11607
|
+
}
|
|
11608
|
+
return Math.max(0, Math.ceil((this.softDeadlineMs - this.nowFn()) / 1e3));
|
|
11609
|
+
}
|
|
11610
|
+
/**
|
|
11611
|
+
* Public extension state snapshot for `get_call_status`. Each value is
|
|
11612
|
+
* "what the agent has left" so the model can decide whether to call
|
|
11613
|
+
* `extend_call_time` at all — exposing both the per-request cap AND
|
|
11614
|
+
* the remaining budget makes greedy / unbounded extension attempts
|
|
11615
|
+
* impossible.
|
|
11616
|
+
*/
|
|
11617
|
+
getExtensionStatus() {
|
|
11618
|
+
const pol = this.extensionPolicy;
|
|
11619
|
+
if (!pol) {
|
|
11620
|
+
return {
|
|
11621
|
+
extensionsUsed: 0,
|
|
11622
|
+
extensionsRemaining: 0,
|
|
11623
|
+
secondsUsedSoFar: 0,
|
|
11624
|
+
secondsAvailable: 0,
|
|
11625
|
+
maxSecondsPerRequest: 0
|
|
11626
|
+
};
|
|
11627
|
+
}
|
|
11628
|
+
return {
|
|
11629
|
+
extensionsUsed: this.extensionsUsed,
|
|
11630
|
+
extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
|
|
11631
|
+
secondsUsedSoFar: this.extensionSecondsUsed,
|
|
11632
|
+
secondsAvailable: Math.max(0, pol.maxTotalExtensionSeconds - this.extensionSecondsUsed),
|
|
11633
|
+
maxSecondsPerRequest: pol.maxSecondsPerRequest
|
|
11634
|
+
};
|
|
11635
|
+
}
|
|
11636
|
+
/**
|
|
11637
|
+
* Grant (or refuse) more time on the call. Auto-approved within all
|
|
11638
|
+
* three policy caps; the granted amount is the min of:
|
|
11639
|
+
*
|
|
11640
|
+
* - the agent's requested seconds (positive integer, clamped > 0)
|
|
11641
|
+
* - policy.maxSecondsPerRequest
|
|
11642
|
+
* - policy.maxTotalExtensionSeconds − seconds already granted
|
|
11643
|
+
*
|
|
11644
|
+
* AND we won't push the new deadline past the hard ceiling
|
|
11645
|
+
* (PHONE_SERVER_MAX_CALL_DURATION_SECONDS from call start). The
|
|
11646
|
+
* returned shape always includes a model-readable `reason` so a
|
|
11647
|
+
* partial grant ("you asked for 5 min, you got 2 min") doesn't
|
|
11648
|
+
* confuse the agent.
|
|
11649
|
+
*
|
|
11650
|
+
* Failure modes (granted: 0):
|
|
11651
|
+
* - no extension policy on this call
|
|
11652
|
+
* - max requests already used
|
|
11653
|
+
* - max total seconds already used
|
|
11654
|
+
* - call already ended
|
|
11655
|
+
* - non-positive `seconds`
|
|
11656
|
+
*/
|
|
11657
|
+
extendCallTime(requestedSeconds, reason) {
|
|
11658
|
+
if (this.ended) {
|
|
11659
|
+
return {
|
|
11660
|
+
granted: false,
|
|
11661
|
+
secondsGranted: 0,
|
|
11662
|
+
secondsRemaining: 0,
|
|
11663
|
+
extensionsRemaining: 0,
|
|
11664
|
+
message: "The call has already ended; no more extensions can be granted."
|
|
11665
|
+
};
|
|
11666
|
+
}
|
|
11667
|
+
const pol = this.extensionPolicy;
|
|
11668
|
+
if (!pol) {
|
|
11669
|
+
return {
|
|
11670
|
+
granted: false,
|
|
11671
|
+
secondsGranted: 0,
|
|
11672
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
11673
|
+
extensionsRemaining: 0,
|
|
11674
|
+
message: "Extensions are not enabled for this call."
|
|
11675
|
+
};
|
|
11676
|
+
}
|
|
11677
|
+
if (this.initialBudgetSeconds <= 0 || this.softDeadlineMs == null || this.callStartedAtMs == null) {
|
|
11678
|
+
return {
|
|
11679
|
+
granted: false,
|
|
11680
|
+
secondsGranted: 0,
|
|
11681
|
+
secondsRemaining: Number.POSITIVE_INFINITY,
|
|
11682
|
+
extensionsRemaining: pol.maxRequestsPerCall - this.extensionsUsed,
|
|
11683
|
+
message: "This call has no soft time budget, so extensions are a no-op."
|
|
11684
|
+
};
|
|
11685
|
+
}
|
|
11686
|
+
if (this.extensionsUsed >= pol.maxRequestsPerCall) {
|
|
11687
|
+
return {
|
|
11688
|
+
granted: false,
|
|
11689
|
+
secondsGranted: 0,
|
|
11690
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
11691
|
+
extensionsRemaining: 0,
|
|
11692
|
+
message: `Out of extensions \u2014 already used ${pol.maxRequestsPerCall}/${pol.maxRequestsPerCall} this call. Wrap up or schedule a callback.`
|
|
11693
|
+
};
|
|
11694
|
+
}
|
|
11695
|
+
const remainingBudgetSeconds = Math.max(0, pol.maxTotalExtensionSeconds - this.extensionSecondsUsed);
|
|
11696
|
+
if (remainingBudgetSeconds <= 0) {
|
|
11697
|
+
return {
|
|
11698
|
+
granted: false,
|
|
11699
|
+
secondsGranted: 0,
|
|
11700
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
11701
|
+
extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
|
|
11702
|
+
message: `Out of extension time \u2014 already granted ${this.extensionSecondsUsed}s of the ${pol.maxTotalExtensionSeconds}s cap.`
|
|
11703
|
+
};
|
|
11704
|
+
}
|
|
11705
|
+
const asked = Math.floor(requestedSeconds);
|
|
11706
|
+
if (!Number.isFinite(asked) || asked <= 0) {
|
|
11707
|
+
return {
|
|
11708
|
+
granted: false,
|
|
11709
|
+
secondsGranted: 0,
|
|
11710
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
11711
|
+
extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
|
|
11712
|
+
message: "extend_call_time requires a positive integer number of seconds."
|
|
11713
|
+
};
|
|
11714
|
+
}
|
|
11715
|
+
let granted = Math.min(asked, pol.maxSecondsPerRequest, remainingBudgetSeconds);
|
|
11716
|
+
const elapsedSeconds = Math.floor((this.nowFn() - this.callStartedAtMs) / 1e3);
|
|
11717
|
+
const hardCeilingRoom = Math.max(
|
|
11718
|
+
0,
|
|
11719
|
+
PHONE_SERVER_MAX_CALL_DURATION_SECONDS - (elapsedSeconds + this.getTimeRemainingSeconds())
|
|
11720
|
+
);
|
|
11721
|
+
granted = Math.min(granted, hardCeilingRoom);
|
|
11722
|
+
if (granted <= 0) {
|
|
11723
|
+
return {
|
|
11724
|
+
granted: false,
|
|
11725
|
+
secondsGranted: 0,
|
|
11726
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
11727
|
+
extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
|
|
11728
|
+
message: "No extension granted \u2014 the call is already at the hard duration ceiling."
|
|
11729
|
+
};
|
|
11730
|
+
}
|
|
11731
|
+
this.extensionsUsed += 1;
|
|
11732
|
+
this.extensionSecondsUsed += granted;
|
|
11733
|
+
this.softDeadlineMs = this.softDeadlineMs + granted * 1e3;
|
|
11734
|
+
this.rearmBudgetTimers();
|
|
11735
|
+
this.emitTranscript("system", `Granted ${granted}s extension (#${this.extensionsUsed}). Reason: ${truncate2(asString5(reason) || "unspecified", 120)}`, {
|
|
11736
|
+
extensionsUsed: this.extensionsUsed,
|
|
11737
|
+
extensionSecondsUsed: this.extensionSecondsUsed,
|
|
11738
|
+
softDeadlineMs: this.softDeadlineMs
|
|
11739
|
+
});
|
|
11740
|
+
return {
|
|
11741
|
+
granted: true,
|
|
11742
|
+
secondsGranted: granted,
|
|
11743
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
11744
|
+
extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
|
|
11745
|
+
message: `Granted ${granted} more seconds (${this.getTimeRemainingSeconds()}s now remaining). ${Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed)} extension(s) left after this one.`
|
|
11746
|
+
};
|
|
11747
|
+
}
|
|
11748
|
+
/**
|
|
11749
|
+
* Capture the agent's `schedule_callback` request. The bridge VALIDATES
|
|
11750
|
+
* (policy allows it, delay is in the legal window, summary present),
|
|
11751
|
+
* builds a transcript digest from {@link recentUtterances}, then fires
|
|
11752
|
+
* {@link onCallbackScheduled} so the API layer can persist + arm the
|
|
11753
|
+
* scheduler. The bridge does NOT itself dial — that's the scheduler's
|
|
11754
|
+
* job, fired at the requested wall-clock time.
|
|
11755
|
+
*
|
|
11756
|
+
* Returning `{ accepted: true }` arms `isCallbackArmed`, which the
|
|
11757
|
+
* end-of-call path uses to skip the legacy operator-query callback
|
|
11758
|
+
* flag (the agent has already declared its own follow-up plan).
|
|
11759
|
+
*/
|
|
11760
|
+
scheduleCallback(req) {
|
|
11761
|
+
if (this.ended) {
|
|
11762
|
+
return { accepted: false, message: "Cannot schedule a callback \u2014 the call has already ended." };
|
|
11763
|
+
}
|
|
11764
|
+
if (this.callbackArmed) {
|
|
11765
|
+
return { accepted: false, message: "A callback has already been scheduled for this call. Stick with that plan." };
|
|
11766
|
+
}
|
|
11767
|
+
const pol = this.callbackPolicy;
|
|
11768
|
+
if (!pol || !pol.allowAutoCallback || pol.maxCallbackChain <= 0) {
|
|
11769
|
+
return {
|
|
11770
|
+
accepted: false,
|
|
11771
|
+
message: "Auto-callbacks are disabled by policy on this call. Tell the caller you will follow up another way."
|
|
11772
|
+
};
|
|
11773
|
+
}
|
|
11774
|
+
const delay = Math.floor(req.delaySeconds);
|
|
11775
|
+
const minDelay = 30;
|
|
11776
|
+
const maxDelay = 7 * 24 * 60 * 60;
|
|
11777
|
+
if (!Number.isFinite(delay) || delay < minDelay) {
|
|
11778
|
+
return {
|
|
11779
|
+
accepted: false,
|
|
11780
|
+
message: `Callbacks must be scheduled at least ${minDelay}s in the future.`
|
|
11781
|
+
};
|
|
11782
|
+
}
|
|
11783
|
+
if (delay > maxDelay) {
|
|
11784
|
+
return {
|
|
11785
|
+
accepted: false,
|
|
11786
|
+
message: `Callbacks must be scheduled within ${Math.floor(maxDelay / 86400)} days.`
|
|
11787
|
+
};
|
|
11788
|
+
}
|
|
11789
|
+
const summary = asString5(req.summary);
|
|
11790
|
+
if (!summary) {
|
|
11791
|
+
return {
|
|
11792
|
+
accepted: false,
|
|
11793
|
+
message: "schedule_callback requires a non-empty `summary` for the next call to pick up from."
|
|
11794
|
+
};
|
|
11795
|
+
}
|
|
11796
|
+
const at = new Date(this.nowFn() + delay * 1e3).toISOString();
|
|
11797
|
+
const digest = this.composeTranscriptDigest();
|
|
11798
|
+
const payload = {
|
|
11799
|
+
at,
|
|
11800
|
+
reason: truncate2(asString5(req.reason) || "no reason given", 240),
|
|
11801
|
+
agentSummary: truncate2(summary, MAX_CALLBACK_SUMMARY_LENGTH),
|
|
11802
|
+
transcriptDigest: digest
|
|
11803
|
+
};
|
|
11804
|
+
try {
|
|
11805
|
+
this.onCallbackScheduled?.(payload);
|
|
11806
|
+
} catch (err) {
|
|
11807
|
+
return { accepted: false, message: `Could not arm callback: ${errorText(err)}` };
|
|
11808
|
+
}
|
|
11809
|
+
this.callbackArmed = true;
|
|
11810
|
+
this.emitTranscript("system", `Callback scheduled for ${at}. Reason: ${payload.reason}`, {
|
|
11811
|
+
scheduledAt: at,
|
|
11812
|
+
summaryLength: payload.agentSummary.length,
|
|
11813
|
+
digestLength: digest.length
|
|
11814
|
+
});
|
|
11815
|
+
return {
|
|
11816
|
+
accepted: true,
|
|
11817
|
+
at,
|
|
11818
|
+
message: `Callback scheduled for ${at}. The next call will pick up with your summary + the transcript so far.`
|
|
11819
|
+
};
|
|
11820
|
+
}
|
|
11821
|
+
/**
|
|
11822
|
+
* Public time-budget snapshot for `get_call_status`. Bundles
|
|
11823
|
+
* everything the agent needs to decide whether to keep going, ask
|
|
11824
|
+
* for more time, or schedule a callback.
|
|
11825
|
+
*/
|
|
11826
|
+
getCallStatus() {
|
|
11827
|
+
return {
|
|
11828
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
11829
|
+
softDeadlineAt: this.softDeadlineMs ? new Date(this.softDeadlineMs).toISOString() : null,
|
|
11830
|
+
extension: this.getExtensionStatus(),
|
|
11831
|
+
callbackAvailable: !!this.callbackPolicy?.allowAutoCallback && (this.callbackPolicy?.maxCallbackChain ?? 0) > 0 && !this.callbackArmed,
|
|
11832
|
+
callbackArmed: this.callbackArmed
|
|
11833
|
+
};
|
|
11834
|
+
}
|
|
11835
|
+
/**
|
|
11836
|
+
* Arm the soft-deadline timer + reminder timers. Called once at hello.
|
|
11837
|
+
* No-op when the bridge has no budget configured.
|
|
11838
|
+
*/
|
|
11839
|
+
startCallBudget() {
|
|
11840
|
+
if (this.initialBudgetSeconds <= 0) return;
|
|
11841
|
+
const nowMs = this.nowFn();
|
|
11842
|
+
this.callStartedAtMs = nowMs;
|
|
11843
|
+
this.softDeadlineMs = nowMs + this.initialBudgetSeconds * 1e3;
|
|
11844
|
+
this.emitTranscript("system", `Call budget armed: ${this.initialBudgetSeconds}s, soft deadline ${new Date(this.softDeadlineMs).toISOString()}.`, {
|
|
11845
|
+
budgetSeconds: this.initialBudgetSeconds
|
|
11846
|
+
});
|
|
11847
|
+
this.rearmBudgetTimers();
|
|
11848
|
+
}
|
|
11849
|
+
/**
|
|
11850
|
+
* Cancel all existing budget timers and re-arm them against
|
|
11851
|
+
* {@link softDeadlineMs}. Called at startCallBudget time AND after
|
|
11852
|
+
* every successful {@link extendCallTime} grant — the timers always
|
|
11853
|
+
* reflect the CURRENT deadline.
|
|
11854
|
+
*/
|
|
11855
|
+
rearmBudgetTimers() {
|
|
11856
|
+
this.clearBudgetTimers();
|
|
11857
|
+
if (this.softDeadlineMs == null) return;
|
|
11858
|
+
const nowMs = this.nowFn();
|
|
11859
|
+
const msToDeadline = this.softDeadlineMs - nowMs;
|
|
11860
|
+
for (const mark of CALL_BUDGET_REMINDER_MARKS_SECONDS) {
|
|
11861
|
+
const msUntilMark = msToDeadline - mark * 1e3;
|
|
11862
|
+
if (msUntilMark <= 0) continue;
|
|
11863
|
+
if (this.firedReminderMarks.has(mark)) continue;
|
|
11864
|
+
const t = this.setTimeoutFn(() => {
|
|
11865
|
+
this.firedReminderMarks.add(mark);
|
|
11866
|
+
this.injectReminder(mark);
|
|
11867
|
+
}, msUntilMark);
|
|
11868
|
+
this.reminderTimers.push(t);
|
|
11869
|
+
}
|
|
11870
|
+
const msUntilSoftEnd = Math.max(0, msToDeadline);
|
|
11871
|
+
this.softEndTimer = this.setTimeoutFn(() => {
|
|
11872
|
+
this.softEndTimer = null;
|
|
11873
|
+
this.onSoftDeadline();
|
|
11874
|
+
}, msUntilSoftEnd);
|
|
11875
|
+
}
|
|
11876
|
+
/** Cancel all currently-armed budget timers. Idempotent. */
|
|
11877
|
+
clearBudgetTimers() {
|
|
11878
|
+
if (this.softEndTimer) {
|
|
11879
|
+
this.clearTimeoutFn(this.softEndTimer);
|
|
11880
|
+
this.softEndTimer = null;
|
|
11881
|
+
}
|
|
11882
|
+
if (this.graceEndTimer) {
|
|
11883
|
+
this.clearTimeoutFn(this.graceEndTimer);
|
|
11884
|
+
this.graceEndTimer = null;
|
|
11885
|
+
}
|
|
11886
|
+
for (const t of this.reminderTimers) {
|
|
11887
|
+
this.clearTimeoutFn(t);
|
|
11888
|
+
}
|
|
11889
|
+
this.reminderTimers = [];
|
|
11890
|
+
}
|
|
11891
|
+
/**
|
|
11892
|
+
* Inject a "you have ~N seconds left" system message into the live
|
|
11893
|
+
* OpenAI session. The model receives it as a `conversation.item.create`
|
|
11894
|
+
* with role:`system`, followed by `response.create` so it can decide
|
|
11895
|
+
* whether to acknowledge it out loud (often it just naturally
|
|
11896
|
+
* accelerates wrap-up; we don't force a verbal "I have 30 seconds").
|
|
11897
|
+
*/
|
|
11898
|
+
injectReminder(secondsRemaining) {
|
|
11899
|
+
if (this.ended || !this.openaiReady) return;
|
|
11900
|
+
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.`;
|
|
11901
|
+
this.safeSend(this.openai, {
|
|
11902
|
+
type: "conversation.item.create",
|
|
11903
|
+
item: { type: "message", role: "system", content: [{ type: "input_text", text }] }
|
|
11904
|
+
});
|
|
11905
|
+
this.safeSend(this.openai, { type: "response.create" });
|
|
11906
|
+
this.emitTranscript("system", `Time reminder injected at T-${secondsRemaining}s.`);
|
|
11907
|
+
}
|
|
11908
|
+
/**
|
|
11909
|
+
* Fires once the soft deadline elapses. Injects a "your time is up"
|
|
11910
|
+
* system message + schedules the grace-window hard end. If the agent
|
|
11911
|
+
* uses the grace window to call `schedule_callback` or `extend_call_time`
|
|
11912
|
+
* the latter can push the deadline forward again — that's fine, the
|
|
11913
|
+
* grace timer is cancelled by {@link extendCallTime} via rearmBudgetTimers.
|
|
11914
|
+
*/
|
|
11915
|
+
onSoftDeadline() {
|
|
11916
|
+
if (this.ended) return;
|
|
11917
|
+
if (this.openaiReady) {
|
|
11918
|
+
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.`;
|
|
11919
|
+
this.safeSend(this.openai, {
|
|
11920
|
+
type: "conversation.item.create",
|
|
11921
|
+
item: { type: "message", role: "system", content: [{ type: "input_text", text }] }
|
|
11922
|
+
});
|
|
11923
|
+
this.safeSend(this.openai, { type: "response.create" });
|
|
11924
|
+
}
|
|
11925
|
+
this.emitTranscript("system", `Soft deadline reached; ${CALL_BUDGET_GRACE_SECONDS}s grace window started.`);
|
|
11926
|
+
this.endedByTimeBudgetFlag = true;
|
|
11927
|
+
this.graceEndTimer = this.setTimeoutFn(() => {
|
|
11928
|
+
this.graceEndTimer = null;
|
|
11929
|
+
if (this.ended) return;
|
|
11930
|
+
this.emitTranscript("system", "Grace window elapsed \u2014 bridge ending call.");
|
|
11931
|
+
this.end("time-budget-exceeded");
|
|
11932
|
+
}, CALL_BUDGET_GRACE_SECONDS * 1e3);
|
|
11933
|
+
}
|
|
11934
|
+
/**
|
|
11935
|
+
* Add an utterance to the rolling buffer used for the callback
|
|
11936
|
+
* transcript digest. Bounded by char count, not entry count, so a
|
|
11937
|
+
* burst of short turns doesn't get pruned prematurely.
|
|
11938
|
+
*/
|
|
11939
|
+
noteUtterance(line) {
|
|
11940
|
+
if (!line) return;
|
|
11941
|
+
this.recentUtterances.push(line);
|
|
11942
|
+
let total = this.recentUtterances.reduce((n, s) => n + s.length, 0);
|
|
11943
|
+
while (total > MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH * 2 && this.recentUtterances.length > 1) {
|
|
11944
|
+
const dropped = this.recentUtterances.shift();
|
|
11945
|
+
total -= dropped.length;
|
|
11946
|
+
}
|
|
11947
|
+
}
|
|
11948
|
+
/**
|
|
11949
|
+
* Compose a transcript digest from the rolling buffer. Used as the
|
|
11950
|
+
* "context from the previous call" payload in {@link scheduleCallback}.
|
|
11951
|
+
* Always honours {@link MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH}.
|
|
11952
|
+
*/
|
|
11953
|
+
composeTranscriptDigest() {
|
|
11954
|
+
const joined = this.recentUtterances.join("\n");
|
|
11955
|
+
if (joined.length <= MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH) return joined;
|
|
11956
|
+
return "\u2026\n" + joined.slice(joined.length - MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH + 2);
|
|
11957
|
+
}
|
|
11160
11958
|
// ─── Teardown ─────────────────────────────────────────
|
|
11959
|
+
/**
|
|
11960
|
+
* v0.9.82 — agent-initiated hangup. Called when the `end_call` tool
|
|
11961
|
+
* fires. Logs a marker, then routes through {@link end} so the
|
|
11962
|
+
* carrier sees the bye frame and `onEnd` fires exactly once (the
|
|
11963
|
+
* same teardown path the human-hangup case takes). The "agent-
|
|
11964
|
+
* requested" reason flows through to the mission transcript so a
|
|
11965
|
+
* post-call audit can tell apart "agent hung up" from "human hung
|
|
11966
|
+
* up" from "time budget exceeded".
|
|
11967
|
+
*
|
|
11968
|
+
* Returns the structured result the tool handler echoes back to the
|
|
11969
|
+
* model — even though by the time the model receives it the line
|
|
11970
|
+
* will already be closed, keeping a consistent return shape lets the
|
|
11971
|
+
* executor JSON-stringify deterministically.
|
|
11972
|
+
*/
|
|
11973
|
+
endByAgentRequest(reason) {
|
|
11974
|
+
if (this.ended) {
|
|
11975
|
+
return { ok: false, message: "Call has already ended." };
|
|
11976
|
+
}
|
|
11977
|
+
const trimmed = (reason ?? "").trim();
|
|
11978
|
+
this.emitTranscript("system", `Agent requested hangup. Reason: ${trimmed || "unspecified"}`, {
|
|
11979
|
+
endedByAgent: true
|
|
11980
|
+
});
|
|
11981
|
+
this.end("agent-requested");
|
|
11982
|
+
return { ok: true, message: "Call ended." };
|
|
11983
|
+
}
|
|
11161
11984
|
/**
|
|
11162
11985
|
* End the bridge. Idempotent — the first call wins, later calls are
|
|
11163
11986
|
* no-ops. Sends the carrier's end-of-call frame (if it has one — 46elks
|
|
@@ -11166,6 +11989,7 @@ var RealtimeVoiceBridge = class {
|
|
|
11166
11989
|
end(reason) {
|
|
11167
11990
|
if (this.ended) return;
|
|
11168
11991
|
this.ended = true;
|
|
11992
|
+
this.clearBudgetTimers();
|
|
11169
11993
|
if (this.droppedFrames > 0) {
|
|
11170
11994
|
this.onTranscript?.({
|
|
11171
11995
|
source: "system",
|
|
@@ -11194,7 +12018,11 @@ var RealtimeVoiceBridge = class {
|
|
|
11194
12018
|
this.openai.close();
|
|
11195
12019
|
} catch {
|
|
11196
12020
|
}
|
|
11197
|
-
this.
|
|
12021
|
+
if (this.endedByTimeBudgetFlag) {
|
|
12022
|
+
this.onEnd?.({ reason, pendingToolCalls, endedByTimeBudget: true });
|
|
12023
|
+
} else {
|
|
12024
|
+
this.onEnd?.({ reason, pendingToolCalls });
|
|
12025
|
+
}
|
|
11198
12026
|
}
|
|
11199
12027
|
// ─── Internals ────────────────────────────────────────
|
|
11200
12028
|
noteDroppedFrame() {
|