@agenticmail/core 0.9.26 → 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 +773 -5
- package/dist/index.d.cts +456 -161
- package/dist/index.d.ts +456 -161
- package/dist/index.js +773 -5
- package/package.json +1 -1
package/dist/index.js
CHANGED
|
@@ -5869,6 +5869,22 @@ var PHONE_SERVER_MAX_CALL_DURATION_SECONDS = 3600;
|
|
|
5869
5869
|
var PHONE_SERVER_MAX_COST_PER_MISSION = 5;
|
|
5870
5870
|
var PHONE_SERVER_MAX_ATTEMPTS = 3;
|
|
5871
5871
|
var PHONE_TASK_MAX_LENGTH = 2e3;
|
|
5872
|
+
var PHONE_SERVER_MAX_EXTENSION_SECONDS_PER_REQUEST = 300;
|
|
5873
|
+
var PHONE_SERVER_MAX_EXTENSION_REQUESTS_PER_CALL = 4;
|
|
5874
|
+
var PHONE_SERVER_MAX_TOTAL_EXTENSION_SECONDS = 600;
|
|
5875
|
+
var DEFAULT_EXTENSION_POLICY = {
|
|
5876
|
+
maxSecondsPerRequest: 120,
|
|
5877
|
+
// 2 minutes
|
|
5878
|
+
maxRequestsPerCall: 2,
|
|
5879
|
+
maxTotalExtensionSeconds: 300
|
|
5880
|
+
// 5 minutes
|
|
5881
|
+
};
|
|
5882
|
+
var PHONE_SERVER_MAX_CALLBACK_CHAIN = 3;
|
|
5883
|
+
var DEFAULT_CALLBACK_POLICY = {
|
|
5884
|
+
allowAutoCallback: true,
|
|
5885
|
+
maxCallbackChain: 2
|
|
5886
|
+
};
|
|
5887
|
+
var PHONE_CALLBACK_MAX_DELAY_SECONDS = 7 * 24 * 60 * 60;
|
|
5872
5888
|
var EU_DIAL_PREFIXES = [
|
|
5873
5889
|
"+30",
|
|
5874
5890
|
"+31",
|
|
@@ -5983,6 +5999,76 @@ function validateAlternativePolicy(value) {
|
|
|
5983
5999
|
}
|
|
5984
6000
|
return [];
|
|
5985
6001
|
}
|
|
6002
|
+
function validateExtensionPolicy(value) {
|
|
6003
|
+
if (value === void 0) return [];
|
|
6004
|
+
if (!isRecord(value)) {
|
|
6005
|
+
return [issue("invalid-extension-policy", "policy.extensionPolicy", "extensionPolicy must be an object")];
|
|
6006
|
+
}
|
|
6007
|
+
const issues = [];
|
|
6008
|
+
const perReq = readPositiveInteger(value.maxSecondsPerRequest);
|
|
6009
|
+
if (perReq === null) {
|
|
6010
|
+
issues.push(issue(
|
|
6011
|
+
"invalid-extension-per-request",
|
|
6012
|
+
"policy.extensionPolicy.maxSecondsPerRequest",
|
|
6013
|
+
"maxSecondsPerRequest must be a positive integer"
|
|
6014
|
+
));
|
|
6015
|
+
}
|
|
6016
|
+
const requests = readPositiveInteger(value.maxRequestsPerCall);
|
|
6017
|
+
if (requests === null) {
|
|
6018
|
+
issues.push(issue(
|
|
6019
|
+
"invalid-extension-requests",
|
|
6020
|
+
"policy.extensionPolicy.maxRequestsPerCall",
|
|
6021
|
+
"maxRequestsPerCall must be a positive integer"
|
|
6022
|
+
));
|
|
6023
|
+
}
|
|
6024
|
+
const total = readPositiveInteger(value.maxTotalExtensionSeconds);
|
|
6025
|
+
if (total === null) {
|
|
6026
|
+
issues.push(issue(
|
|
6027
|
+
"invalid-extension-total",
|
|
6028
|
+
"policy.extensionPolicy.maxTotalExtensionSeconds",
|
|
6029
|
+
"maxTotalExtensionSeconds must be a positive integer"
|
|
6030
|
+
));
|
|
6031
|
+
}
|
|
6032
|
+
return issues;
|
|
6033
|
+
}
|
|
6034
|
+
function validateCallbackPolicy(value) {
|
|
6035
|
+
if (value === void 0) return [];
|
|
6036
|
+
if (!isRecord(value)) {
|
|
6037
|
+
return [issue("invalid-callback-policy", "policy.callbackPolicy", "callbackPolicy must be an object")];
|
|
6038
|
+
}
|
|
6039
|
+
const issues = [];
|
|
6040
|
+
if (readBoolean(value.allowAutoCallback) === null) {
|
|
6041
|
+
issues.push(issue(
|
|
6042
|
+
"invalid-callback-allow",
|
|
6043
|
+
"policy.callbackPolicy.allowAutoCallback",
|
|
6044
|
+
"allowAutoCallback must be boolean"
|
|
6045
|
+
));
|
|
6046
|
+
}
|
|
6047
|
+
const chain = readNonNegativeNumber(value.maxCallbackChain);
|
|
6048
|
+
if (chain === null || !Number.isInteger(chain)) {
|
|
6049
|
+
issues.push(issue(
|
|
6050
|
+
"invalid-callback-chain",
|
|
6051
|
+
"policy.callbackPolicy.maxCallbackChain",
|
|
6052
|
+
"maxCallbackChain must be a non-negative integer"
|
|
6053
|
+
));
|
|
6054
|
+
}
|
|
6055
|
+
return issues;
|
|
6056
|
+
}
|
|
6057
|
+
function resolveExtensionPolicy(input) {
|
|
6058
|
+
const src = input ?? DEFAULT_EXTENSION_POLICY;
|
|
6059
|
+
return {
|
|
6060
|
+
maxSecondsPerRequest: Math.min(src.maxSecondsPerRequest, PHONE_SERVER_MAX_EXTENSION_SECONDS_PER_REQUEST),
|
|
6061
|
+
maxRequestsPerCall: Math.min(src.maxRequestsPerCall, PHONE_SERVER_MAX_EXTENSION_REQUESTS_PER_CALL),
|
|
6062
|
+
maxTotalExtensionSeconds: Math.min(src.maxTotalExtensionSeconds, PHONE_SERVER_MAX_TOTAL_EXTENSION_SECONDS)
|
|
6063
|
+
};
|
|
6064
|
+
}
|
|
6065
|
+
function resolveCallbackPolicy(input) {
|
|
6066
|
+
const src = input ?? DEFAULT_CALLBACK_POLICY;
|
|
6067
|
+
return {
|
|
6068
|
+
allowAutoCallback: src.allowAutoCallback,
|
|
6069
|
+
maxCallbackChain: Math.min(src.maxCallbackChain, PHONE_SERVER_MAX_CALLBACK_CHAIN)
|
|
6070
|
+
};
|
|
6071
|
+
}
|
|
5986
6072
|
function validatePhoneMissionPolicy(policy) {
|
|
5987
6073
|
const issues = [];
|
|
5988
6074
|
if (!isRecord(policy)) {
|
|
@@ -6017,6 +6103,8 @@ function validatePhoneMissionPolicy(policy) {
|
|
|
6017
6103
|
}
|
|
6018
6104
|
issues.push(...validateConfirmPolicy(policy.confirmPolicy));
|
|
6019
6105
|
issues.push(...validateAlternativePolicy(policy.alternativePolicy));
|
|
6106
|
+
issues.push(...validateExtensionPolicy(policy.extensionPolicy));
|
|
6107
|
+
issues.push(...validateCallbackPolicy(policy.callbackPolicy));
|
|
6020
6108
|
if (issues.length > 0) return { ok: false, issues };
|
|
6021
6109
|
return {
|
|
6022
6110
|
ok: true,
|
|
@@ -6031,7 +6119,14 @@ function validatePhoneMissionPolicy(policy) {
|
|
|
6031
6119
|
confirmPolicy: policy.confirmPolicy,
|
|
6032
6120
|
alternativePolicy: {
|
|
6033
6121
|
maxTimeShiftMinutes: policy.alternativePolicy.maxTimeShiftMinutes
|
|
6034
|
-
}
|
|
6122
|
+
},
|
|
6123
|
+
// The extension + callback policies are optional in the caller's
|
|
6124
|
+
// input but we ALWAYS materialise them in the resolved policy so
|
|
6125
|
+
// every downstream consumer (the bridge, the scheduler, the
|
|
6126
|
+
// manager) can read a concrete value without juggling undefined.
|
|
6127
|
+
// Caller-omitted → DEFAULT_*. Caller-set → clamped to server caps.
|
|
6128
|
+
extensionPolicy: resolveExtensionPolicy(policy.extensionPolicy),
|
|
6129
|
+
callbackPolicy: resolveCallbackPolicy(policy.callbackPolicy)
|
|
6035
6130
|
},
|
|
6036
6131
|
issues: []
|
|
6037
6132
|
};
|
|
@@ -6266,6 +6361,13 @@ function readOperatorQueries(mission) {
|
|
|
6266
6361
|
if (!Array.isArray(value)) return [];
|
|
6267
6362
|
return value.filter((item) => Boolean(item) && typeof item === "object" && !Array.isArray(item) && typeof item.id === "string" && typeof item.question === "string");
|
|
6268
6363
|
}
|
|
6364
|
+
function readChainDepth(mission) {
|
|
6365
|
+
const raw = mission.metadata.callbackChainDepth;
|
|
6366
|
+
if (typeof raw === "number" && Number.isFinite(raw) && raw >= 0) return Math.floor(raw);
|
|
6367
|
+
const sc = mission.metadata.scheduledCallback;
|
|
6368
|
+
if (sc && Number.isFinite(sc.chainDepth)) return Math.floor(sc.chainDepth);
|
|
6369
|
+
return 0;
|
|
6370
|
+
}
|
|
6269
6371
|
function escapeLike(value) {
|
|
6270
6372
|
return value.replace(/[\\%_]/g, "\\$&");
|
|
6271
6373
|
}
|
|
@@ -6284,6 +6386,26 @@ function buildCallbackTask(originalTask, query) {
|
|
|
6284
6386
|
return `${continuity}
|
|
6285
6387
|
${originalTask.slice(0, room)}`.slice(0, PHONE_TASK_MAX_LENGTH);
|
|
6286
6388
|
}
|
|
6389
|
+
function buildScheduledCallbackTask(originalTask, payload) {
|
|
6390
|
+
const continuity = [
|
|
6391
|
+
"# Call continuity \u2014 auto-callback",
|
|
6392
|
+
'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.',
|
|
6393
|
+
"",
|
|
6394
|
+
`## Why you arranged this callback
|
|
6395
|
+
${payload.reason}`,
|
|
6396
|
+
"",
|
|
6397
|
+
`## Notes you left for yourself (your own summary at the end of the prior call)
|
|
6398
|
+
${payload.agentSummary}`,
|
|
6399
|
+
"",
|
|
6400
|
+
`## What was actually said on the prior call (verbatim digest)
|
|
6401
|
+
${payload.transcriptDigest}`,
|
|
6402
|
+
"",
|
|
6403
|
+
"# Original task"
|
|
6404
|
+
].join("\n");
|
|
6405
|
+
const room = Math.max(0, PHONE_TASK_MAX_LENGTH - continuity.length - 1);
|
|
6406
|
+
return `${continuity}
|
|
6407
|
+
${originalTask.slice(0, room)}`.slice(0, PHONE_TASK_MAX_LENGTH);
|
|
6408
|
+
}
|
|
6287
6409
|
function parseJson(value, fallback) {
|
|
6288
6410
|
if (!value) return fallback;
|
|
6289
6411
|
try {
|
|
@@ -6956,6 +7078,134 @@ var PhoneManager = class {
|
|
|
6956
7078
|
throw err;
|
|
6957
7079
|
}
|
|
6958
7080
|
}
|
|
7081
|
+
// ─── Scheduled callbacks (v0.9.81 — schedule_callback tool) ──────────
|
|
7082
|
+
/**
|
|
7083
|
+
* Persist a `schedule_callback` request to the mission. Called from
|
|
7084
|
+
* the realtime bridge's `onCallbackScheduled` hook. The scheduler
|
|
7085
|
+
* picks this up later when `payload.at <= now`. ChainDepth is
|
|
7086
|
+
* computed from the parent's metadata so {@link triggerScheduledCallback}
|
|
7087
|
+
* can enforce policy.callbackPolicy.maxCallbackChain without
|
|
7088
|
+
* walking back through the mission history.
|
|
7089
|
+
*
|
|
7090
|
+
* Returns the updated mission, or null if the mission isn't known.
|
|
7091
|
+
* Idempotent on the `mission.metadata.scheduledCallback.at` key: if
|
|
7092
|
+
* a scheduled callback already exists on the mission this writes a
|
|
7093
|
+
* SECOND copy on `scheduledCallbacks` as an audit trail but does
|
|
7094
|
+
* NOT overwrite the active record (the bridge only allows one per
|
|
7095
|
+
* call anyway; the audit log is a belt-and-braces guard against
|
|
7096
|
+
* the unusual case where a server restart re-runs the bridge logic).
|
|
7097
|
+
*/
|
|
7098
|
+
armScheduledCallback(missionId, payload) {
|
|
7099
|
+
const mission = this.getMission(missionId);
|
|
7100
|
+
if (!mission) return null;
|
|
7101
|
+
const parentDepth = readChainDepth(mission);
|
|
7102
|
+
const record = {
|
|
7103
|
+
at: payload.at,
|
|
7104
|
+
reason: payload.reason,
|
|
7105
|
+
agentSummary: payload.agentSummary,
|
|
7106
|
+
transcriptDigest: payload.transcriptDigest,
|
|
7107
|
+
chainDepth: parentDepth + 1,
|
|
7108
|
+
status: "pending",
|
|
7109
|
+
armedAt: (/* @__PURE__ */ new Date()).toISOString()
|
|
7110
|
+
};
|
|
7111
|
+
return this.updateMissionStatus(mission.id, mission.status, {
|
|
7112
|
+
scheduledCallback: record
|
|
7113
|
+
}, [{
|
|
7114
|
+
at: record.armedAt,
|
|
7115
|
+
source: "system",
|
|
7116
|
+
text: `Scheduled callback armed for ${record.at} (chain depth ${record.chainDepth}). Reason: ${record.reason}`,
|
|
7117
|
+
metadata: { scheduledAt: record.at, chainDepth: record.chainDepth }
|
|
7118
|
+
}]);
|
|
7119
|
+
}
|
|
7120
|
+
/**
|
|
7121
|
+
* All missions with a `scheduledCallback.status === 'pending'` whose
|
|
7122
|
+
* `at` is <= now. The scheduler's per-tick worklist. Pass an upper
|
|
7123
|
+
* bound on count so a backlog doesn't dial every overdue callback in
|
|
7124
|
+
* one frame.
|
|
7125
|
+
*/
|
|
7126
|
+
findDueScheduledCallbacks(nowIso, limit = 16) {
|
|
7127
|
+
const rows = this.db.prepare(
|
|
7128
|
+
"SELECT * FROM phone_missions WHERE metadata_json LIKE '%scheduledCallback%' AND metadata_json LIKE '%pending%' LIMIT ?"
|
|
7129
|
+
).all(limit * 4);
|
|
7130
|
+
return rows.map(rowToMission).filter((mission) => {
|
|
7131
|
+
const sc = mission.metadata.scheduledCallback;
|
|
7132
|
+
return sc && sc.status === "pending" && sc.at <= nowIso;
|
|
7133
|
+
}).slice(0, limit);
|
|
7134
|
+
}
|
|
7135
|
+
/**
|
|
7136
|
+
* Dial a due scheduled callback. Mirrors {@link triggerCallback} for
|
|
7137
|
+
* the operator-query path:
|
|
7138
|
+
*
|
|
7139
|
+
* 1. Reject if the mission's policy.callbackPolicy disallows it OR
|
|
7140
|
+
* `chainDepth > maxCallbackChain` (no infinite chains).
|
|
7141
|
+
* 2. Transition status pending → dialing BEFORE dialing so a
|
|
7142
|
+
* concurrent tick can't double-dial.
|
|
7143
|
+
* 3. Build the continuation task with prior-call context and dial.
|
|
7144
|
+
* 4. On success: write `status: 'fired'` + the new mission id.
|
|
7145
|
+
* 5. On failure: write `status: 'pending'` + `lastError` so the
|
|
7146
|
+
* next tick can retry, then rethrow.
|
|
7147
|
+
*
|
|
7148
|
+
* Returns `null` if the mission isn't known or has no due callback.
|
|
7149
|
+
*/
|
|
7150
|
+
async triggerScheduledCallback(missionId, options = {}) {
|
|
7151
|
+
const mission = this.getMission(missionId);
|
|
7152
|
+
if (!mission) return null;
|
|
7153
|
+
const sc = mission.metadata.scheduledCallback;
|
|
7154
|
+
if (!sc) return null;
|
|
7155
|
+
if (sc.status !== "pending") return null;
|
|
7156
|
+
const callbackPol = mission.policy.callbackPolicy;
|
|
7157
|
+
if (!callbackPol || !callbackPol.allowAutoCallback || sc.chainDepth > callbackPol.maxCallbackChain) {
|
|
7158
|
+
const updated = this.updateMissionStatus(mission.id, mission.status, {
|
|
7159
|
+
scheduledCallback: { ...sc, status: "failed", lastError: "policy denies callback (chain or disabled)" }
|
|
7160
|
+
}, [{
|
|
7161
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7162
|
+
source: "system",
|
|
7163
|
+
text: `Scheduled callback denied by policy (chainDepth=${sc.chainDepth}, max=${callbackPol?.maxCallbackChain ?? 0}).`
|
|
7164
|
+
}]);
|
|
7165
|
+
return updated ? { mission: updated, callbackMission: updated } : null;
|
|
7166
|
+
}
|
|
7167
|
+
this.updateMissionStatus(mission.id, mission.status, {
|
|
7168
|
+
scheduledCallback: { ...sc, status: "dialing" }
|
|
7169
|
+
}, [{
|
|
7170
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7171
|
+
source: "system",
|
|
7172
|
+
text: `Dialing scheduled callback (chain depth ${sc.chainDepth}).`
|
|
7173
|
+
}]);
|
|
7174
|
+
try {
|
|
7175
|
+
const result = await this.startMission(mission.agentId, {
|
|
7176
|
+
to: mission.to,
|
|
7177
|
+
task: buildScheduledCallbackTask(mission.task, {
|
|
7178
|
+
reason: sc.reason,
|
|
7179
|
+
agentSummary: sc.agentSummary,
|
|
7180
|
+
transcriptDigest: sc.transcriptDigest
|
|
7181
|
+
}),
|
|
7182
|
+
policy: mission.policy
|
|
7183
|
+
}, options);
|
|
7184
|
+
const callbackMission = this.updateMissionStatus(result.mission.id, result.mission.status, {
|
|
7185
|
+
callbackChainDepth: sc.chainDepth,
|
|
7186
|
+
callbackParentMissionId: mission.id
|
|
7187
|
+
}, []) ?? result.mission;
|
|
7188
|
+
const linked = this.updateMissionStatus(mission.id, mission.status, {
|
|
7189
|
+
scheduledCallback: {
|
|
7190
|
+
...sc,
|
|
7191
|
+
status: "fired",
|
|
7192
|
+
firedAt: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7193
|
+
callbackMissionId: result.mission.id
|
|
7194
|
+
}
|
|
7195
|
+
}, []);
|
|
7196
|
+
return { mission: linked, callbackMission };
|
|
7197
|
+
} catch (err) {
|
|
7198
|
+
const message = err?.message ?? String(err);
|
|
7199
|
+
this.updateMissionStatus(mission.id, mission.status, {
|
|
7200
|
+
scheduledCallback: { ...sc, status: "pending", lastError: message }
|
|
7201
|
+
}, [{
|
|
7202
|
+
at: (/* @__PURE__ */ new Date()).toISOString(),
|
|
7203
|
+
source: "system",
|
|
7204
|
+
text: `Scheduled callback dial failed (${message}); will retry on next scheduler tick.`
|
|
7205
|
+
}]);
|
|
7206
|
+
throw err;
|
|
7207
|
+
}
|
|
7208
|
+
}
|
|
6959
7209
|
build46ElksCallRequest(config, mission) {
|
|
6960
7210
|
const timeout = Math.min(Math.max(mission.policy.maxCallDurationSeconds, 1), PHONE_SERVER_MAX_CALL_DURATION_SECONDS);
|
|
6961
7211
|
return {
|
|
@@ -8795,6 +9045,57 @@ var LOAD_SKILL_TOOL = {
|
|
|
8795
9045
|
additionalProperties: false
|
|
8796
9046
|
}
|
|
8797
9047
|
};
|
|
9048
|
+
var GET_CALL_STATUS_TOOL = {
|
|
9049
|
+
type: "function",
|
|
9050
|
+
name: "get_call_status",
|
|
9051
|
+
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.",
|
|
9052
|
+
parameters: {
|
|
9053
|
+
type: "object",
|
|
9054
|
+
properties: {}
|
|
9055
|
+
}
|
|
9056
|
+
};
|
|
9057
|
+
var EXTEND_CALL_TIME_TOOL = {
|
|
9058
|
+
type: "function",
|
|
9059
|
+
name: "extend_call_time",
|
|
9060
|
+
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.",
|
|
9061
|
+
parameters: {
|
|
9062
|
+
type: "object",
|
|
9063
|
+
properties: {
|
|
9064
|
+
seconds: {
|
|
9065
|
+
type: "number",
|
|
9066
|
+
description: "How many MORE seconds you need. Positive integer. The server caps each grant."
|
|
9067
|
+
},
|
|
9068
|
+
reason: {
|
|
9069
|
+
type: "string",
|
|
9070
|
+
description: "One short line on why \u2014 kept on the mission transcript for review."
|
|
9071
|
+
}
|
|
9072
|
+
},
|
|
9073
|
+
required: ["seconds"]
|
|
9074
|
+
}
|
|
9075
|
+
};
|
|
9076
|
+
var SCHEDULE_CALLBACK_TOOL = {
|
|
9077
|
+
type: "function",
|
|
9078
|
+
name: "schedule_callback",
|
|
9079
|
+
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.',
|
|
9080
|
+
parameters: {
|
|
9081
|
+
type: "object",
|
|
9082
|
+
properties: {
|
|
9083
|
+
delay_seconds: {
|
|
9084
|
+
type: "number",
|
|
9085
|
+
description: 'How many seconds from now to call back. Minimum 30, maximum 604800 (7 days). Use larger delays generously \u2014 "tomorrow morning" is ~57600s.'
|
|
9086
|
+
},
|
|
9087
|
+
reason: {
|
|
9088
|
+
type: "string",
|
|
9089
|
+
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".'
|
|
9090
|
+
},
|
|
9091
|
+
summary_for_next_call: {
|
|
9092
|
+
type: "string",
|
|
9093
|
+
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."
|
|
9094
|
+
}
|
|
9095
|
+
},
|
|
9096
|
+
required: ["delay_seconds", "summary_for_next_call"]
|
|
9097
|
+
}
|
|
9098
|
+
};
|
|
8798
9099
|
var REALTIME_TOOL_DEFINITIONS = {
|
|
8799
9100
|
ask_operator: ASK_OPERATOR_TOOL,
|
|
8800
9101
|
web_search: WEB_SEARCH_TOOL,
|
|
@@ -8802,7 +9103,10 @@ var REALTIME_TOOL_DEFINITIONS = {
|
|
|
8802
9103
|
get_datetime: GET_DATETIME_TOOL,
|
|
8803
9104
|
search_email: SEARCH_EMAIL_TOOL,
|
|
8804
9105
|
search_skills: SEARCH_SKILLS_TOOL,
|
|
8805
|
-
load_skill: LOAD_SKILL_TOOL
|
|
9106
|
+
load_skill: LOAD_SKILL_TOOL,
|
|
9107
|
+
get_call_status: GET_CALL_STATUS_TOOL,
|
|
9108
|
+
extend_call_time: EXTEND_CALL_TIME_TOOL,
|
|
9109
|
+
schedule_callback: SCHEDULE_CALLBACK_TOOL
|
|
8806
9110
|
};
|
|
8807
9111
|
function buildRealtimeToolGuidance(tools) {
|
|
8808
9112
|
if (tools.length === 0) return "";
|
|
@@ -8831,6 +9135,24 @@ function buildRealtimeToolGuidance(tools) {
|
|
|
8831
9135
|
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.`
|
|
8832
9136
|
);
|
|
8833
9137
|
}
|
|
9138
|
+
if (names.has("get_call_status") || names.has("extend_call_time") || names.has("schedule_callback")) {
|
|
9139
|
+
lines.push(
|
|
9140
|
+
"# Managing your time on this call",
|
|
9141
|
+
"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.",
|
|
9142
|
+
"",
|
|
9143
|
+
"If you need more time to finish the job:",
|
|
9144
|
+
`- 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.`,
|
|
9145
|
+
"",
|
|
9146
|
+
"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:",
|
|
9147
|
+
`- 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.`,
|
|
9148
|
+
`- 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.`,
|
|
9149
|
+
"- You can ONLY schedule one callback per call. Choose deliberately.",
|
|
9150
|
+
"",
|
|
9151
|
+
"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.",
|
|
9152
|
+
"",
|
|
9153
|
+
"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."
|
|
9154
|
+
);
|
|
9155
|
+
}
|
|
8834
9156
|
return lines.join("\n");
|
|
8835
9157
|
}
|
|
8836
9158
|
function toolErrorText(err) {
|
|
@@ -9036,6 +9358,26 @@ ${task}`);
|
|
|
9036
9358
|
'# 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
|
|
9037
9359
|
);
|
|
9038
9360
|
}
|
|
9361
|
+
const budget = opts.callBudget;
|
|
9362
|
+
if (budget && budget.seconds > 0) {
|
|
9363
|
+
const mins = Math.round(budget.seconds / 60);
|
|
9364
|
+
const human = mins >= 1 ? `about ${mins} minute(s)` : `${budget.seconds} seconds`;
|
|
9365
|
+
const tips = [];
|
|
9366
|
+
if (budget.extensionEnabled) {
|
|
9367
|
+
tips.push(
|
|
9368
|
+
"If you need more time, call extend_call_time({ seconds, reason }) BEFORE you run out. Auto-approved within the call's extension policy."
|
|
9369
|
+
);
|
|
9370
|
+
}
|
|
9371
|
+
if (budget.callbackEnabled) {
|
|
9372
|
+
tips.push(
|
|
9373
|
+
"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."
|
|
9374
|
+
);
|
|
9375
|
+
}
|
|
9376
|
+
sections.push(
|
|
9377
|
+
`# Your time on this call
|
|
9378
|
+
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") : "")
|
|
9379
|
+
);
|
|
9380
|
+
}
|
|
9039
9381
|
const toolGuidance = opts.toolGuidance?.trim();
|
|
9040
9382
|
if (toolGuidance) {
|
|
9041
9383
|
sections.push(toolGuidance);
|
|
@@ -9075,6 +9417,10 @@ function buildRealtimeSessionConfig(opts) {
|
|
|
9075
9417
|
function buildOpenAIRealtimeUrl(model = DEFAULT_REALTIME_MODEL) {
|
|
9076
9418
|
return `${OPENAI_REALTIME_URL}?model=${encodeURIComponent(model || DEFAULT_REALTIME_MODEL)}`;
|
|
9077
9419
|
}
|
|
9420
|
+
var MAX_CALLBACK_SUMMARY_LENGTH = 1500;
|
|
9421
|
+
var MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH = 2500;
|
|
9422
|
+
var CALL_BUDGET_REMINDER_MARKS_SECONDS = [120, 30];
|
|
9423
|
+
var CALL_BUDGET_GRACE_SECONDS = 30;
|
|
9078
9424
|
var RealtimeVoiceBridge = class {
|
|
9079
9425
|
carrier;
|
|
9080
9426
|
openai;
|
|
@@ -9085,6 +9431,46 @@ var RealtimeVoiceBridge = class {
|
|
|
9085
9431
|
maxToolCallMs;
|
|
9086
9432
|
onTranscript;
|
|
9087
9433
|
onEnd;
|
|
9434
|
+
/** Injectable clock + timers (tests substitute fakes). */
|
|
9435
|
+
nowFn;
|
|
9436
|
+
setTimeoutFn;
|
|
9437
|
+
clearTimeoutFn;
|
|
9438
|
+
/** v0.9.81 — extension / callback state. */
|
|
9439
|
+
extensionPolicy;
|
|
9440
|
+
callbackPolicy;
|
|
9441
|
+
onCallbackScheduled;
|
|
9442
|
+
/** Initial soft budget, in seconds. 0 = no bridge-side timer (legacy). */
|
|
9443
|
+
initialBudgetSeconds;
|
|
9444
|
+
/** Wall-clock ms when the call started, set on first carrier hello. */
|
|
9445
|
+
callStartedAtMs = null;
|
|
9446
|
+
/** Wall-clock ms when the soft deadline fires. Bumped by extensions. */
|
|
9447
|
+
softDeadlineMs = null;
|
|
9448
|
+
/** Soft-end timer (fires once, then schedules the grace timer). */
|
|
9449
|
+
softEndTimer = null;
|
|
9450
|
+
/** Final hard-end timer that fires after the grace window. */
|
|
9451
|
+
graceEndTimer = null;
|
|
9452
|
+
/** Reminder timers for the T-N marks. Cleared/re-armed on extensions. */
|
|
9453
|
+
reminderTimers = [];
|
|
9454
|
+
/** Marks (in seconds-remaining) we've already fired this call. Dedup
|
|
9455
|
+
* prevents re-injecting the same reminder after an extension if the
|
|
9456
|
+
* new deadline still has us past the same mark. */
|
|
9457
|
+
firedReminderMarks = /* @__PURE__ */ new Set();
|
|
9458
|
+
/** Count of extensions granted this call. */
|
|
9459
|
+
extensionsUsed = 0;
|
|
9460
|
+
/** Total extra seconds granted across all extensions this call. */
|
|
9461
|
+
extensionSecondsUsed = 0;
|
|
9462
|
+
/** True once the agent's schedule_callback request was accepted. */
|
|
9463
|
+
callbackArmed = false;
|
|
9464
|
+
/** Captured for the API layer when the soft deadline fires. */
|
|
9465
|
+
endedByTimeBudgetFlag = false;
|
|
9466
|
+
/**
|
|
9467
|
+
* Sliding window of recent assistant + system utterances, used to
|
|
9468
|
+
* build the transcript digest carried into a scheduled callback.
|
|
9469
|
+
* Capped at {@link MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH} chars so
|
|
9470
|
+
* the digest itself can always be produced cheaply even on a long
|
|
9471
|
+
* call.
|
|
9472
|
+
*/
|
|
9473
|
+
recentUtterances = [];
|
|
9088
9474
|
/** Carrier `hello`/`start` received — the call leg is live. */
|
|
9089
9475
|
helloSeen = false;
|
|
9090
9476
|
/** OpenAI socket open + `session.update` sent. */
|
|
@@ -9142,6 +9528,13 @@ var RealtimeVoiceBridge = class {
|
|
|
9142
9528
|
this.maxToolCallMs = opts.maxToolCallMs ?? REALTIME_TOOL_CALL_TIMEOUT_MS;
|
|
9143
9529
|
this.onTranscript = opts.onTranscript;
|
|
9144
9530
|
this.onEnd = opts.onEnd;
|
|
9531
|
+
this.nowFn = opts.now ?? Date.now;
|
|
9532
|
+
this.setTimeoutFn = opts.setTimeoutFn ?? setTimeout;
|
|
9533
|
+
this.clearTimeoutFn = opts.clearTimeoutFn ?? clearTimeout;
|
|
9534
|
+
this.initialBudgetSeconds = opts.callBudgetSeconds && opts.callBudgetSeconds > 0 ? Math.floor(opts.callBudgetSeconds) : 0;
|
|
9535
|
+
this.extensionPolicy = opts.extensionPolicy;
|
|
9536
|
+
this.callbackPolicy = opts.callbackPolicy;
|
|
9537
|
+
this.onCallbackScheduled = opts.onCallbackScheduled;
|
|
9145
9538
|
}
|
|
9146
9539
|
/** True once the bridge has ended. */
|
|
9147
9540
|
get isEnded() {
|
|
@@ -9303,6 +9696,7 @@ var RealtimeVoiceBridge = class {
|
|
|
9303
9696
|
from: event.from,
|
|
9304
9697
|
to: event.to
|
|
9305
9698
|
});
|
|
9699
|
+
this.startCallBudget();
|
|
9306
9700
|
return;
|
|
9307
9701
|
}
|
|
9308
9702
|
if (event.kind === "audio") {
|
|
@@ -9381,14 +9775,20 @@ var RealtimeVoiceBridge = class {
|
|
|
9381
9775
|
case "response.output_audio_transcript.done":
|
|
9382
9776
|
case "response.audio_transcript.done": {
|
|
9383
9777
|
const text = this.assistantTranscript.trim();
|
|
9384
|
-
if (text)
|
|
9778
|
+
if (text) {
|
|
9779
|
+
this.emitTranscript("agent", text);
|
|
9780
|
+
this.noteUtterance(`Agent: ${text}`);
|
|
9781
|
+
}
|
|
9385
9782
|
this.assistantTranscript = "";
|
|
9386
9783
|
return;
|
|
9387
9784
|
}
|
|
9388
9785
|
// Caller speech transcription, when input transcription is on.
|
|
9389
9786
|
case "conversation.item.input_audio_transcription.completed": {
|
|
9390
9787
|
const text = typeof event.transcript === "string" ? event.transcript.trim() : "";
|
|
9391
|
-
if (text)
|
|
9788
|
+
if (text) {
|
|
9789
|
+
this.emitTranscript("provider", text, { speaker: "caller" });
|
|
9790
|
+
this.noteUtterance(`Caller: ${text}`);
|
|
9791
|
+
}
|
|
9392
9792
|
return;
|
|
9393
9793
|
}
|
|
9394
9794
|
// A new output item was added to the response. When it is a
|
|
@@ -9508,6 +9908,369 @@ var RealtimeVoiceBridge = class {
|
|
|
9508
9908
|
});
|
|
9509
9909
|
this.safeSend(this.openai, { type: "response.create" });
|
|
9510
9910
|
}
|
|
9911
|
+
// ─── Call-budget timers / extensions / callback (v0.9.81) ─────────
|
|
9912
|
+
/** True if the agent's `schedule_callback` request has been accepted. */
|
|
9913
|
+
get isCallbackArmed() {
|
|
9914
|
+
return this.callbackArmed;
|
|
9915
|
+
}
|
|
9916
|
+
/**
|
|
9917
|
+
* Seconds remaining on the current soft deadline, floored at 0. Returns
|
|
9918
|
+
* the initial budget if hello hasn't fired yet, and `Infinity` if no
|
|
9919
|
+
* call budget was configured (legacy mode). Used by `get_call_status`.
|
|
9920
|
+
*/
|
|
9921
|
+
getTimeRemainingSeconds() {
|
|
9922
|
+
if (this.initialBudgetSeconds <= 0) return Number.POSITIVE_INFINITY;
|
|
9923
|
+
if (this.callStartedAtMs == null || this.softDeadlineMs == null) {
|
|
9924
|
+
return this.initialBudgetSeconds;
|
|
9925
|
+
}
|
|
9926
|
+
return Math.max(0, Math.ceil((this.softDeadlineMs - this.nowFn()) / 1e3));
|
|
9927
|
+
}
|
|
9928
|
+
/**
|
|
9929
|
+
* Public extension state snapshot for `get_call_status`. Each value is
|
|
9930
|
+
* "what the agent has left" so the model can decide whether to call
|
|
9931
|
+
* `extend_call_time` at all — exposing both the per-request cap AND
|
|
9932
|
+
* the remaining budget makes greedy / unbounded extension attempts
|
|
9933
|
+
* impossible.
|
|
9934
|
+
*/
|
|
9935
|
+
getExtensionStatus() {
|
|
9936
|
+
const pol = this.extensionPolicy;
|
|
9937
|
+
if (!pol) {
|
|
9938
|
+
return {
|
|
9939
|
+
extensionsUsed: 0,
|
|
9940
|
+
extensionsRemaining: 0,
|
|
9941
|
+
secondsUsedSoFar: 0,
|
|
9942
|
+
secondsAvailable: 0,
|
|
9943
|
+
maxSecondsPerRequest: 0
|
|
9944
|
+
};
|
|
9945
|
+
}
|
|
9946
|
+
return {
|
|
9947
|
+
extensionsUsed: this.extensionsUsed,
|
|
9948
|
+
extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
|
|
9949
|
+
secondsUsedSoFar: this.extensionSecondsUsed,
|
|
9950
|
+
secondsAvailable: Math.max(0, pol.maxTotalExtensionSeconds - this.extensionSecondsUsed),
|
|
9951
|
+
maxSecondsPerRequest: pol.maxSecondsPerRequest
|
|
9952
|
+
};
|
|
9953
|
+
}
|
|
9954
|
+
/**
|
|
9955
|
+
* Grant (or refuse) more time on the call. Auto-approved within all
|
|
9956
|
+
* three policy caps; the granted amount is the min of:
|
|
9957
|
+
*
|
|
9958
|
+
* - the agent's requested seconds (positive integer, clamped > 0)
|
|
9959
|
+
* - policy.maxSecondsPerRequest
|
|
9960
|
+
* - policy.maxTotalExtensionSeconds − seconds already granted
|
|
9961
|
+
*
|
|
9962
|
+
* AND we won't push the new deadline past the hard ceiling
|
|
9963
|
+
* (PHONE_SERVER_MAX_CALL_DURATION_SECONDS from call start). The
|
|
9964
|
+
* returned shape always includes a model-readable `reason` so a
|
|
9965
|
+
* partial grant ("you asked for 5 min, you got 2 min") doesn't
|
|
9966
|
+
* confuse the agent.
|
|
9967
|
+
*
|
|
9968
|
+
* Failure modes (granted: 0):
|
|
9969
|
+
* - no extension policy on this call
|
|
9970
|
+
* - max requests already used
|
|
9971
|
+
* - max total seconds already used
|
|
9972
|
+
* - call already ended
|
|
9973
|
+
* - non-positive `seconds`
|
|
9974
|
+
*/
|
|
9975
|
+
extendCallTime(requestedSeconds, reason) {
|
|
9976
|
+
if (this.ended) {
|
|
9977
|
+
return {
|
|
9978
|
+
granted: false,
|
|
9979
|
+
secondsGranted: 0,
|
|
9980
|
+
secondsRemaining: 0,
|
|
9981
|
+
extensionsRemaining: 0,
|
|
9982
|
+
message: "The call has already ended; no more extensions can be granted."
|
|
9983
|
+
};
|
|
9984
|
+
}
|
|
9985
|
+
const pol = this.extensionPolicy;
|
|
9986
|
+
if (!pol) {
|
|
9987
|
+
return {
|
|
9988
|
+
granted: false,
|
|
9989
|
+
secondsGranted: 0,
|
|
9990
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
9991
|
+
extensionsRemaining: 0,
|
|
9992
|
+
message: "Extensions are not enabled for this call."
|
|
9993
|
+
};
|
|
9994
|
+
}
|
|
9995
|
+
if (this.initialBudgetSeconds <= 0 || this.softDeadlineMs == null || this.callStartedAtMs == null) {
|
|
9996
|
+
return {
|
|
9997
|
+
granted: false,
|
|
9998
|
+
secondsGranted: 0,
|
|
9999
|
+
secondsRemaining: Number.POSITIVE_INFINITY,
|
|
10000
|
+
extensionsRemaining: pol.maxRequestsPerCall - this.extensionsUsed,
|
|
10001
|
+
message: "This call has no soft time budget, so extensions are a no-op."
|
|
10002
|
+
};
|
|
10003
|
+
}
|
|
10004
|
+
if (this.extensionsUsed >= pol.maxRequestsPerCall) {
|
|
10005
|
+
return {
|
|
10006
|
+
granted: false,
|
|
10007
|
+
secondsGranted: 0,
|
|
10008
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
10009
|
+
extensionsRemaining: 0,
|
|
10010
|
+
message: `Out of extensions \u2014 already used ${pol.maxRequestsPerCall}/${pol.maxRequestsPerCall} this call. Wrap up or schedule a callback.`
|
|
10011
|
+
};
|
|
10012
|
+
}
|
|
10013
|
+
const remainingBudgetSeconds = Math.max(0, pol.maxTotalExtensionSeconds - this.extensionSecondsUsed);
|
|
10014
|
+
if (remainingBudgetSeconds <= 0) {
|
|
10015
|
+
return {
|
|
10016
|
+
granted: false,
|
|
10017
|
+
secondsGranted: 0,
|
|
10018
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
10019
|
+
extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
|
|
10020
|
+
message: `Out of extension time \u2014 already granted ${this.extensionSecondsUsed}s of the ${pol.maxTotalExtensionSeconds}s cap.`
|
|
10021
|
+
};
|
|
10022
|
+
}
|
|
10023
|
+
const asked = Math.floor(requestedSeconds);
|
|
10024
|
+
if (!Number.isFinite(asked) || asked <= 0) {
|
|
10025
|
+
return {
|
|
10026
|
+
granted: false,
|
|
10027
|
+
secondsGranted: 0,
|
|
10028
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
10029
|
+
extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
|
|
10030
|
+
message: "extend_call_time requires a positive integer number of seconds."
|
|
10031
|
+
};
|
|
10032
|
+
}
|
|
10033
|
+
let granted = Math.min(asked, pol.maxSecondsPerRequest, remainingBudgetSeconds);
|
|
10034
|
+
const elapsedSeconds = Math.floor((this.nowFn() - this.callStartedAtMs) / 1e3);
|
|
10035
|
+
const maxAllowedFromStart = 3600;
|
|
10036
|
+
const hardCeilingRoom = Math.max(0, maxAllowedFromStart - (elapsedSeconds + this.getTimeRemainingSeconds()));
|
|
10037
|
+
granted = Math.min(granted, hardCeilingRoom);
|
|
10038
|
+
if (granted <= 0) {
|
|
10039
|
+
return {
|
|
10040
|
+
granted: false,
|
|
10041
|
+
secondsGranted: 0,
|
|
10042
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
10043
|
+
extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
|
|
10044
|
+
message: "No extension granted \u2014 the call is already at the hard duration ceiling."
|
|
10045
|
+
};
|
|
10046
|
+
}
|
|
10047
|
+
this.extensionsUsed += 1;
|
|
10048
|
+
this.extensionSecondsUsed += granted;
|
|
10049
|
+
this.softDeadlineMs = this.softDeadlineMs + granted * 1e3;
|
|
10050
|
+
this.rearmBudgetTimers();
|
|
10051
|
+
this.emitTranscript("system", `Granted ${granted}s extension (#${this.extensionsUsed}). Reason: ${truncate2(asString5(reason) || "unspecified", 120)}`, {
|
|
10052
|
+
extensionsUsed: this.extensionsUsed,
|
|
10053
|
+
extensionSecondsUsed: this.extensionSecondsUsed,
|
|
10054
|
+
softDeadlineMs: this.softDeadlineMs
|
|
10055
|
+
});
|
|
10056
|
+
return {
|
|
10057
|
+
granted: true,
|
|
10058
|
+
secondsGranted: granted,
|
|
10059
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
10060
|
+
extensionsRemaining: Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed),
|
|
10061
|
+
message: `Granted ${granted} more seconds (${this.getTimeRemainingSeconds()}s now remaining). ${Math.max(0, pol.maxRequestsPerCall - this.extensionsUsed)} extension(s) left after this one.`
|
|
10062
|
+
};
|
|
10063
|
+
}
|
|
10064
|
+
/**
|
|
10065
|
+
* Capture the agent's `schedule_callback` request. The bridge VALIDATES
|
|
10066
|
+
* (policy allows it, delay is in the legal window, summary present),
|
|
10067
|
+
* builds a transcript digest from {@link recentUtterances}, then fires
|
|
10068
|
+
* {@link onCallbackScheduled} so the API layer can persist + arm the
|
|
10069
|
+
* scheduler. The bridge does NOT itself dial — that's the scheduler's
|
|
10070
|
+
* job, fired at the requested wall-clock time.
|
|
10071
|
+
*
|
|
10072
|
+
* Returning `{ accepted: true }` arms `isCallbackArmed`, which the
|
|
10073
|
+
* end-of-call path uses to skip the legacy operator-query callback
|
|
10074
|
+
* flag (the agent has already declared its own follow-up plan).
|
|
10075
|
+
*/
|
|
10076
|
+
scheduleCallback(req) {
|
|
10077
|
+
if (this.ended) {
|
|
10078
|
+
return { accepted: false, message: "Cannot schedule a callback \u2014 the call has already ended." };
|
|
10079
|
+
}
|
|
10080
|
+
if (this.callbackArmed) {
|
|
10081
|
+
return { accepted: false, message: "A callback has already been scheduled for this call. Stick with that plan." };
|
|
10082
|
+
}
|
|
10083
|
+
const pol = this.callbackPolicy;
|
|
10084
|
+
if (!pol || !pol.allowAutoCallback || pol.maxCallbackChain <= 0) {
|
|
10085
|
+
return {
|
|
10086
|
+
accepted: false,
|
|
10087
|
+
message: "Auto-callbacks are disabled by policy on this call. Tell the caller you will follow up another way."
|
|
10088
|
+
};
|
|
10089
|
+
}
|
|
10090
|
+
const delay = Math.floor(req.delaySeconds);
|
|
10091
|
+
const minDelay = 30;
|
|
10092
|
+
const maxDelay = 7 * 24 * 60 * 60;
|
|
10093
|
+
if (!Number.isFinite(delay) || delay < minDelay) {
|
|
10094
|
+
return {
|
|
10095
|
+
accepted: false,
|
|
10096
|
+
message: `Callbacks must be scheduled at least ${minDelay}s in the future.`
|
|
10097
|
+
};
|
|
10098
|
+
}
|
|
10099
|
+
if (delay > maxDelay) {
|
|
10100
|
+
return {
|
|
10101
|
+
accepted: false,
|
|
10102
|
+
message: `Callbacks must be scheduled within ${Math.floor(maxDelay / 86400)} days.`
|
|
10103
|
+
};
|
|
10104
|
+
}
|
|
10105
|
+
const summary = asString5(req.summary);
|
|
10106
|
+
if (!summary) {
|
|
10107
|
+
return {
|
|
10108
|
+
accepted: false,
|
|
10109
|
+
message: "schedule_callback requires a non-empty `summary` for the next call to pick up from."
|
|
10110
|
+
};
|
|
10111
|
+
}
|
|
10112
|
+
const at = new Date(this.nowFn() + delay * 1e3).toISOString();
|
|
10113
|
+
const digest = this.composeTranscriptDigest();
|
|
10114
|
+
const payload = {
|
|
10115
|
+
at,
|
|
10116
|
+
reason: truncate2(asString5(req.reason) || "no reason given", 240),
|
|
10117
|
+
agentSummary: truncate2(summary, MAX_CALLBACK_SUMMARY_LENGTH),
|
|
10118
|
+
transcriptDigest: digest
|
|
10119
|
+
};
|
|
10120
|
+
try {
|
|
10121
|
+
this.onCallbackScheduled?.(payload);
|
|
10122
|
+
} catch (err) {
|
|
10123
|
+
return { accepted: false, message: `Could not arm callback: ${errorText(err)}` };
|
|
10124
|
+
}
|
|
10125
|
+
this.callbackArmed = true;
|
|
10126
|
+
this.emitTranscript("system", `Callback scheduled for ${at}. Reason: ${payload.reason}`, {
|
|
10127
|
+
scheduledAt: at,
|
|
10128
|
+
summaryLength: payload.agentSummary.length,
|
|
10129
|
+
digestLength: digest.length
|
|
10130
|
+
});
|
|
10131
|
+
return {
|
|
10132
|
+
accepted: true,
|
|
10133
|
+
at,
|
|
10134
|
+
message: `Callback scheduled for ${at}. The next call will pick up with your summary + the transcript so far.`
|
|
10135
|
+
};
|
|
10136
|
+
}
|
|
10137
|
+
/**
|
|
10138
|
+
* Public time-budget snapshot for `get_call_status`. Bundles
|
|
10139
|
+
* everything the agent needs to decide whether to keep going, ask
|
|
10140
|
+
* for more time, or schedule a callback.
|
|
10141
|
+
*/
|
|
10142
|
+
getCallStatus() {
|
|
10143
|
+
return {
|
|
10144
|
+
secondsRemaining: this.getTimeRemainingSeconds(),
|
|
10145
|
+
softDeadlineAt: this.softDeadlineMs ? new Date(this.softDeadlineMs).toISOString() : null,
|
|
10146
|
+
extension: this.getExtensionStatus(),
|
|
10147
|
+
callbackAvailable: !!this.callbackPolicy?.allowAutoCallback && (this.callbackPolicy?.maxCallbackChain ?? 0) > 0 && !this.callbackArmed,
|
|
10148
|
+
callbackArmed: this.callbackArmed
|
|
10149
|
+
};
|
|
10150
|
+
}
|
|
10151
|
+
/**
|
|
10152
|
+
* Arm the soft-deadline timer + reminder timers. Called once at hello.
|
|
10153
|
+
* No-op when the bridge has no budget configured.
|
|
10154
|
+
*/
|
|
10155
|
+
startCallBudget() {
|
|
10156
|
+
if (this.initialBudgetSeconds <= 0) return;
|
|
10157
|
+
const nowMs = this.nowFn();
|
|
10158
|
+
this.callStartedAtMs = nowMs;
|
|
10159
|
+
this.softDeadlineMs = nowMs + this.initialBudgetSeconds * 1e3;
|
|
10160
|
+
this.emitTranscript("system", `Call budget armed: ${this.initialBudgetSeconds}s, soft deadline ${new Date(this.softDeadlineMs).toISOString()}.`, {
|
|
10161
|
+
budgetSeconds: this.initialBudgetSeconds
|
|
10162
|
+
});
|
|
10163
|
+
this.rearmBudgetTimers();
|
|
10164
|
+
}
|
|
10165
|
+
/**
|
|
10166
|
+
* Cancel all existing budget timers and re-arm them against
|
|
10167
|
+
* {@link softDeadlineMs}. Called at startCallBudget time AND after
|
|
10168
|
+
* every successful {@link extendCallTime} grant — the timers always
|
|
10169
|
+
* reflect the CURRENT deadline.
|
|
10170
|
+
*/
|
|
10171
|
+
rearmBudgetTimers() {
|
|
10172
|
+
this.clearBudgetTimers();
|
|
10173
|
+
if (this.softDeadlineMs == null) return;
|
|
10174
|
+
const nowMs = this.nowFn();
|
|
10175
|
+
const msToDeadline = this.softDeadlineMs - nowMs;
|
|
10176
|
+
for (const mark of CALL_BUDGET_REMINDER_MARKS_SECONDS) {
|
|
10177
|
+
const msUntilMark = msToDeadline - mark * 1e3;
|
|
10178
|
+
if (msUntilMark <= 0) continue;
|
|
10179
|
+
if (this.firedReminderMarks.has(mark)) continue;
|
|
10180
|
+
const t = this.setTimeoutFn(() => {
|
|
10181
|
+
this.firedReminderMarks.add(mark);
|
|
10182
|
+
this.injectReminder(mark);
|
|
10183
|
+
}, msUntilMark);
|
|
10184
|
+
this.reminderTimers.push(t);
|
|
10185
|
+
}
|
|
10186
|
+
const msUntilSoftEnd = Math.max(0, msToDeadline);
|
|
10187
|
+
this.softEndTimer = this.setTimeoutFn(() => {
|
|
10188
|
+
this.softEndTimer = null;
|
|
10189
|
+
this.onSoftDeadline();
|
|
10190
|
+
}, msUntilSoftEnd);
|
|
10191
|
+
}
|
|
10192
|
+
/** Cancel all currently-armed budget timers. Idempotent. */
|
|
10193
|
+
clearBudgetTimers() {
|
|
10194
|
+
if (this.softEndTimer) {
|
|
10195
|
+
this.clearTimeoutFn(this.softEndTimer);
|
|
10196
|
+
this.softEndTimer = null;
|
|
10197
|
+
}
|
|
10198
|
+
if (this.graceEndTimer) {
|
|
10199
|
+
this.clearTimeoutFn(this.graceEndTimer);
|
|
10200
|
+
this.graceEndTimer = null;
|
|
10201
|
+
}
|
|
10202
|
+
for (const t of this.reminderTimers) {
|
|
10203
|
+
this.clearTimeoutFn(t);
|
|
10204
|
+
}
|
|
10205
|
+
this.reminderTimers = [];
|
|
10206
|
+
}
|
|
10207
|
+
/**
|
|
10208
|
+
* Inject a "you have ~N seconds left" system message into the live
|
|
10209
|
+
* OpenAI session. The model receives it as a `conversation.item.create`
|
|
10210
|
+
* with role:`system`, followed by `response.create` so it can decide
|
|
10211
|
+
* whether to acknowledge it out loud (often it just naturally
|
|
10212
|
+
* accelerates wrap-up; we don't force a verbal "I have 30 seconds").
|
|
10213
|
+
*/
|
|
10214
|
+
injectReminder(secondsRemaining) {
|
|
10215
|
+
if (this.ended || !this.openaiReady) return;
|
|
10216
|
+
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.`;
|
|
10217
|
+
this.safeSend(this.openai, {
|
|
10218
|
+
type: "conversation.item.create",
|
|
10219
|
+
item: { type: "message", role: "system", content: [{ type: "input_text", text }] }
|
|
10220
|
+
});
|
|
10221
|
+
this.safeSend(this.openai, { type: "response.create" });
|
|
10222
|
+
this.emitTranscript("system", `Time reminder injected at T-${secondsRemaining}s.`);
|
|
10223
|
+
}
|
|
10224
|
+
/**
|
|
10225
|
+
* Fires once the soft deadline elapses. Injects a "your time is up"
|
|
10226
|
+
* system message + schedules the grace-window hard end. If the agent
|
|
10227
|
+
* uses the grace window to call `schedule_callback` or `extend_call_time`
|
|
10228
|
+
* the latter can push the deadline forward again — that's fine, the
|
|
10229
|
+
* grace timer is cancelled by {@link extendCallTime} via rearmBudgetTimers.
|
|
10230
|
+
*/
|
|
10231
|
+
onSoftDeadline() {
|
|
10232
|
+
if (this.ended) return;
|
|
10233
|
+
if (this.openaiReady) {
|
|
10234
|
+
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.`;
|
|
10235
|
+
this.safeSend(this.openai, {
|
|
10236
|
+
type: "conversation.item.create",
|
|
10237
|
+
item: { type: "message", role: "system", content: [{ type: "input_text", text }] }
|
|
10238
|
+
});
|
|
10239
|
+
this.safeSend(this.openai, { type: "response.create" });
|
|
10240
|
+
}
|
|
10241
|
+
this.emitTranscript("system", `Soft deadline reached; ${CALL_BUDGET_GRACE_SECONDS}s grace window started.`);
|
|
10242
|
+
this.endedByTimeBudgetFlag = true;
|
|
10243
|
+
this.graceEndTimer = this.setTimeoutFn(() => {
|
|
10244
|
+
this.graceEndTimer = null;
|
|
10245
|
+
if (this.ended) return;
|
|
10246
|
+
this.emitTranscript("system", "Grace window elapsed \u2014 bridge ending call.");
|
|
10247
|
+
this.end("time-budget-exceeded");
|
|
10248
|
+
}, CALL_BUDGET_GRACE_SECONDS * 1e3);
|
|
10249
|
+
}
|
|
10250
|
+
/**
|
|
10251
|
+
* Add an utterance to the rolling buffer used for the callback
|
|
10252
|
+
* transcript digest. Bounded by char count, not entry count, so a
|
|
10253
|
+
* burst of short turns doesn't get pruned prematurely.
|
|
10254
|
+
*/
|
|
10255
|
+
noteUtterance(line) {
|
|
10256
|
+
if (!line) return;
|
|
10257
|
+
this.recentUtterances.push(line);
|
|
10258
|
+
let total = this.recentUtterances.reduce((n, s) => n + s.length, 0);
|
|
10259
|
+
while (total > MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH * 2 && this.recentUtterances.length > 1) {
|
|
10260
|
+
const dropped = this.recentUtterances.shift();
|
|
10261
|
+
total -= dropped.length;
|
|
10262
|
+
}
|
|
10263
|
+
}
|
|
10264
|
+
/**
|
|
10265
|
+
* Compose a transcript digest from the rolling buffer. Used as the
|
|
10266
|
+
* "context from the previous call" payload in {@link scheduleCallback}.
|
|
10267
|
+
* Always honours {@link MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH}.
|
|
10268
|
+
*/
|
|
10269
|
+
composeTranscriptDigest() {
|
|
10270
|
+
const joined = this.recentUtterances.join("\n");
|
|
10271
|
+
if (joined.length <= MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH) return joined;
|
|
10272
|
+
return "\u2026\n" + joined.slice(joined.length - MAX_CALLBACK_TRANSCRIPT_DIGEST_LENGTH + 2);
|
|
10273
|
+
}
|
|
9511
10274
|
// ─── Teardown ─────────────────────────────────────────
|
|
9512
10275
|
/**
|
|
9513
10276
|
* End the bridge. Idempotent — the first call wins, later calls are
|
|
@@ -9517,6 +10280,7 @@ var RealtimeVoiceBridge = class {
|
|
|
9517
10280
|
end(reason) {
|
|
9518
10281
|
if (this.ended) return;
|
|
9519
10282
|
this.ended = true;
|
|
10283
|
+
this.clearBudgetTimers();
|
|
9520
10284
|
if (this.droppedFrames > 0) {
|
|
9521
10285
|
this.onTranscript?.({
|
|
9522
10286
|
source: "system",
|
|
@@ -9545,7 +10309,11 @@ var RealtimeVoiceBridge = class {
|
|
|
9545
10309
|
this.openai.close();
|
|
9546
10310
|
} catch {
|
|
9547
10311
|
}
|
|
9548
|
-
this.
|
|
10312
|
+
if (this.endedByTimeBudgetFlag) {
|
|
10313
|
+
this.onEnd?.({ reason, pendingToolCalls, endedByTimeBudget: true });
|
|
10314
|
+
} else {
|
|
10315
|
+
this.onEnd?.({ reason, pendingToolCalls });
|
|
10316
|
+
}
|
|
9549
10317
|
}
|
|
9550
10318
|
// ─── Internals ────────────────────────────────────────
|
|
9551
10319
|
noteDroppedFrame() {
|