stageflow 0.35.0 → 0.39.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +18 -8
- package/dist/agent/claudeAdapter.js +12 -0
- package/dist/agent/piAdapter.d.ts +9 -1
- package/dist/agent/piAdapter.js +30 -2
- package/dist/agent/piIsolatedMcp.js +14 -2
- package/dist/agent/piIsolatedMcpProbe.d.ts +5 -1
- package/dist/agent/piIsolatedMcpProbe.js +16 -30
- package/dist/agent/port.d.ts +7 -0
- package/dist/agents/agentId.d.ts +8 -0
- package/dist/agents/agentId.js +27 -0
- package/dist/agents/approvalBroker.d.ts +16 -0
- package/dist/agents/approvalBroker.js +45 -0
- package/dist/agents/authorChat.d.ts +19 -0
- package/dist/agents/authorChat.js +63 -0
- package/dist/agents/chatTurn.d.ts +23 -0
- package/dist/agents/chatTurn.js +77 -0
- package/dist/agents/communications/agentmail.d.ts +19 -0
- package/dist/agents/communications/agentmail.js +46 -0
- package/dist/agents/communications/agentmailListener.d.ts +59 -0
- package/dist/agents/communications/agentmailListener.js +183 -0
- package/dist/agents/communications/allowlist.d.ts +10 -0
- package/dist/agents/communications/allowlist.js +36 -0
- package/dist/agents/communications/channelConnections.d.ts +3 -0
- package/dist/agents/communications/channelConnections.js +51 -0
- package/dist/agents/communications/delivery.d.ts +53 -0
- package/dist/agents/communications/delivery.js +142 -0
- package/dist/agents/communications/email.d.ts +26 -0
- package/dist/agents/communications/email.js +20 -0
- package/dist/agents/communications/identity.d.ts +13 -0
- package/dist/agents/communications/identity.js +18 -0
- package/dist/agents/communications/imessageListener.d.ts +41 -0
- package/dist/agents/communications/imessageListener.js +92 -0
- package/dist/agents/communications/inbound.d.ts +63 -0
- package/dist/agents/communications/inbound.js +143 -0
- package/dist/agents/communications/listener.d.ts +36 -0
- package/dist/agents/communications/listener.js +38 -0
- package/dist/agents/communications/photon.d.ts +19 -0
- package/dist/agents/communications/photon.js +38 -0
- package/dist/agents/communications/port.d.ts +27 -0
- package/dist/agents/communications/port.js +6 -0
- package/dist/agents/communications/slack.d.ts +28 -0
- package/dist/agents/communications/slack.js +65 -0
- package/dist/agents/communications/supervisor.d.ts +36 -0
- package/dist/agents/communications/supervisor.js +96 -0
- package/dist/agents/communications/telegram.d.ts +19 -0
- package/dist/agents/communications/telegram.js +52 -0
- package/dist/agents/definitionSummary.d.ts +13 -0
- package/dist/agents/definitionSummary.js +91 -0
- package/dist/agents/delegate.d.ts +3 -0
- package/dist/agents/delegate.js +8 -0
- package/dist/agents/errors.d.ts +10 -0
- package/dist/agents/errors.js +32 -0
- package/dist/agents/guardrails.d.ts +24 -0
- package/dist/agents/guardrails.js +39 -0
- package/dist/agents/historyFit.d.ts +10 -0
- package/dist/agents/historyFit.js +26 -0
- package/dist/agents/hostContext.d.ts +54 -0
- package/dist/agents/hostContext.js +156 -0
- package/dist/agents/ledgers.d.ts +29 -0
- package/dist/agents/ledgers.js +31 -0
- package/dist/agents/lineDiff.d.ts +12 -0
- package/dist/agents/lineDiff.js +46 -0
- package/dist/agents/ndjson.d.ts +9 -0
- package/dist/agents/ndjson.js +47 -0
- package/dist/agents/otlpExport.d.ts +9 -0
- package/dist/agents/otlpExport.js +58 -0
- package/dist/agents/otlpSink.d.ts +7 -0
- package/dist/agents/otlpSink.js +23 -0
- package/dist/agents/policy.d.ts +31 -0
- package/dist/agents/policy.js +84 -0
- package/dist/agents/projectRoot.d.ts +5 -0
- package/dist/agents/projectRoot.js +15 -0
- package/dist/agents/runtime/piRuntime.d.ts +52 -0
- package/dist/agents/runtime/piRuntime.js +474 -0
- package/dist/agents/runtime/types.d.ts +77 -0
- package/dist/agents/runtime/types.js +26 -0
- package/dist/agents/sandbox.d.ts +17 -0
- package/dist/agents/sandbox.js +10 -0
- package/dist/agents/scheduleSource.d.ts +32 -0
- package/dist/agents/scheduleSource.js +100 -0
- package/dist/agents/service.d.ts +298 -0
- package/dist/agents/service.js +1105 -0
- package/dist/agents/sessions.d.ts +38 -0
- package/dist/agents/sessions.js +321 -0
- package/dist/agents/store.d.ts +277 -0
- package/dist/agents/store.js +643 -0
- package/dist/agents/trace.d.ts +27 -0
- package/dist/agents/trace.js +29 -0
- package/dist/agents/turnLifecycle.d.ts +72 -0
- package/dist/agents/turnLifecycle.js +157 -0
- package/dist/agents/webChat/publicChat.d.ts +36 -0
- package/dist/agents/webChat/publicChat.js +290 -0
- package/dist/agents/webChat/rateLimit.d.ts +15 -0
- package/dist/agents/webChat/rateLimit.js +33 -0
- package/dist/agents/webChat/slug.d.ts +2 -0
- package/dist/agents/webChat/slug.js +9 -0
- package/dist/channels/config.d.ts +11 -0
- package/dist/channels/config.js +95 -0
- package/dist/channels/configWriter.d.ts +40 -0
- package/dist/channels/configWriter.js +148 -0
- package/dist/channels/connectionEdits.d.ts +34 -0
- package/dist/channels/connectionEdits.js +53 -0
- package/dist/channels/errorInfo.d.ts +18 -0
- package/dist/channels/errorInfo.js +71 -0
- package/dist/channels/errors.d.ts +12 -0
- package/dist/channels/errors.js +13 -0
- package/dist/channels/health.d.ts +41 -0
- package/dist/channels/health.js +99 -0
- package/dist/channels/host.d.ts +55 -0
- package/dist/channels/host.js +185 -0
- package/dist/channels/http.d.ts +31 -0
- package/dist/channels/http.js +79 -0
- package/dist/channels/index.d.ts +10 -0
- package/dist/channels/index.js +9 -0
- package/dist/channels/ledger.d.ts +37 -0
- package/dist/channels/ledger.js +95 -0
- package/dist/channels/providers/agentmail.d.ts +26 -0
- package/dist/channels/providers/agentmail.js +94 -0
- package/dist/channels/providers/kit.d.ts +40 -0
- package/dist/channels/providers/kit.js +78 -0
- package/dist/channels/providers/photonImessage.d.ts +45 -0
- package/dist/channels/providers/photonImessage.js +188 -0
- package/dist/channels/providers/photonWhatsapp.d.ts +49 -0
- package/dist/channels/providers/photonWhatsapp.js +146 -0
- package/dist/channels/providers/slack.d.ts +25 -0
- package/dist/channels/providers/slack.js +102 -0
- package/dist/channels/providers/telegram.d.ts +21 -0
- package/dist/channels/providers/telegram.js +101 -0
- package/dist/channels/providers.d.ts +5 -0
- package/dist/channels/providers.js +22 -0
- package/dist/channels/registry.d.ts +54 -0
- package/dist/channels/registry.js +186 -0
- package/dist/channels/secretStore.d.ts +21 -0
- package/dist/channels/secretStore.js +75 -0
- package/dist/channels/secrets.d.ts +15 -0
- package/dist/channels/secrets.js +50 -0
- package/dist/channels/service.d.ts +54 -0
- package/dist/channels/service.js +307 -0
- package/dist/channels/stageChannels.d.ts +45 -0
- package/dist/channels/stageChannels.js +110 -0
- package/dist/channels/types.d.ts +169 -0
- package/dist/channels/types.js +1 -0
- package/dist/cli/agentsCommand.d.ts +14 -0
- package/dist/cli/agentsCommand.js +291 -0
- package/dist/cli/doctorCommand.js +3 -1
- package/dist/cli.js +9 -0
- package/dist/config/browseCatalog.js +4 -11
- package/dist/config/draftPackage.d.ts +2 -0
- package/dist/config/draftPackage.js +8 -0
- package/dist/config/hostConfig.js +8 -0
- package/dist/config/loadAgent.d.ts +266 -0
- package/dist/config/loadAgent.js +374 -0
- package/dist/config/loadPipeline.js +31 -4
- package/dist/config/loadStage.js +15 -0
- package/dist/config/mcpHostCatalog.d.ts +50 -0
- package/dist/config/mcpHostCatalog.js +405 -0
- package/dist/config/mcpOAuth.d.ts +37 -0
- package/dist/config/mcpOAuth.js +263 -0
- package/dist/config/mcpRedaction.d.ts +42 -0
- package/dist/config/mcpRedaction.js +314 -0
- package/dist/config/mcpToolCache.d.ts +20 -0
- package/dist/config/mcpToolCache.js +43 -0
- package/dist/config/mergePipelineIncludes.d.ts +5 -0
- package/dist/config/mergePipelineIncludes.js +52 -0
- package/dist/config/pipelineStageKeys.js +1 -0
- package/dist/config/resolveStageMcpServers.d.ts +35 -3
- package/dist/config/resolveStageMcpServers.js +124 -9
- package/dist/config/validateCatalog.d.ts +4 -1
- package/dist/config/validateCatalog.js +45 -1
- package/dist/errors/codes.d.ts +1 -1
- package/dist/errors/codes.js +1 -0
- package/dist/mcp/agentTools.d.ts +8 -0
- package/dist/mcp/agentTools.js +132 -0
- package/dist/mcp/catalogTools.js +7 -9
- package/dist/mcp/deps.d.ts +6 -0
- package/dist/mcp/pipelineAuthorSupport.d.ts +19 -0
- package/dist/mcp/pipelineAuthorSupport.js +10 -0
- package/dist/mcp/pipelineAuthorTools.d.ts +8 -0
- package/dist/mcp/pipelineAuthorTools.js +109 -0
- package/dist/mcp/server.js +5 -0
- package/dist/mcp/tools.js +4 -0
- package/dist/mcp/triggerTools.js +32 -0
- package/dist/memory/access.d.ts +36 -0
- package/dist/memory/access.js +116 -0
- package/dist/memory/agentIssues.d.ts +3 -0
- package/dist/memory/agentIssues.js +23 -0
- package/dist/memory/bashDeny.d.ts +6 -0
- package/dist/memory/bashDeny.js +35 -0
- package/dist/memory/host.d.ts +51 -0
- package/dist/memory/host.js +394 -0
- package/dist/memory/ids.d.ts +15 -0
- package/dist/memory/ids.js +31 -0
- package/dist/memory/worker.d.ts +1 -0
- package/dist/memory/worker.js +84 -0
- package/dist/models/catalog.d.ts +113 -0
- package/dist/models/catalog.js +238 -0
- package/dist/models/response.d.ts +29 -0
- package/dist/models/response.js +25 -0
- package/dist/operatorAgent/agentAuthorContext.d.ts +17 -0
- package/dist/operatorAgent/agentAuthorContext.js +32 -0
- package/dist/operatorAgent/fakeHost.js +2 -0
- package/dist/operatorAgent/host.js +6 -3
- package/dist/operatorAgent/index.d.ts +4 -0
- package/dist/operatorAgent/index.js +4 -0
- package/dist/operatorAgent/piModel.js +2 -0
- package/dist/operatorAgent/profiles/agentAuthor.d.ts +11 -0
- package/dist/operatorAgent/profiles/agentAuthor.js +192 -0
- package/dist/operatorAgent/profiles/agentAuthorPlaybook.d.ts +1 -0
- package/dist/operatorAgent/profiles/agentAuthorPlaybook.js +29 -0
- package/dist/operatorAgent/profiles/workshopAuthor.js +2 -2
- package/dist/operatorAgent/profiles/workshopAuthorTools.js +3 -3
- package/dist/operatorAgent/proposals.d.ts +1 -0
- package/dist/operatorAgent/proposals.js +23 -2
- package/dist/package-meta.d.ts +1 -1
- package/dist/package-meta.js +1 -1
- package/dist/preflight/channelChecks.d.ts +17 -0
- package/dist/preflight/channelChecks.js +106 -0
- package/dist/preflight/pipelinePreflight.d.ts +9 -2
- package/dist/preflight/pipelinePreflight.js +10 -1
- package/dist/runstore/createStore.d.ts +2 -0
- package/dist/runstore/createStore.js +8 -0
- package/dist/runstore/overviewSummary.d.ts +56 -0
- package/dist/runstore/overviewSummary.js +134 -0
- package/dist/runstore/port.d.ts +3 -0
- package/dist/runstore/sqlite/SqliteRunStore.d.ts +2 -0
- package/dist/runstore/sqlite/SqliteRunStore.js +18 -0
- package/dist/runstore/sqlite/migrations/013-agents.d.ts +12 -0
- package/dist/runstore/sqlite/migrations/013-agents.js +156 -0
- package/dist/runstore/sqlite/migrations/014-agent-deliveries.d.ts +11 -0
- package/dist/runstore/sqlite/migrations/014-agent-deliveries.js +32 -0
- package/dist/runstore/sqlite/migrations/index.js +4 -0
- package/dist/runtime/pipelineRunner.js +3 -1
- package/dist/runtime/repositoryMaterialize.d.ts +1 -1
- package/dist/runtime/runManager.js +4 -1
- package/dist/runtime/stageAttemptBootstrap.js +31 -1
- package/dist/runtime/stageEnvironment.d.ts +2 -0
- package/dist/runtime/stageEnvironment.js +6 -1
- package/dist/runtime/stageProcessLauncher.js +54 -1
- package/dist/runtime/stageSecrets.d.ts +6 -1
- package/dist/runtime/stageSecrets.js +34 -1
- package/dist/server/agentAuthorRoutes.d.ts +5 -0
- package/dist/server/agentAuthorRoutes.js +64 -0
- package/dist/server/agentRoutes.d.ts +5 -0
- package/dist/server/agentRoutes.js +323 -0
- package/dist/server/bootstrap.d.ts +11 -0
- package/dist/server/bootstrap.js +40 -10
- package/dist/server/channelRoutes.d.ts +9 -0
- package/dist/server/channelRoutes.js +88 -0
- package/dist/server/controlToken.js +8 -0
- package/dist/server/http.d.ts +8 -1
- package/dist/server/http.js +183 -20
- package/dist/server/mcpHost.js +1 -0
- package/dist/server/memoryRoutes.d.ts +5 -0
- package/dist/server/memoryRoutes.js +146 -0
- package/dist/server/projectMcpRoutes.d.ts +1 -0
- package/dist/server/projectMcpRoutes.js +241 -3
- package/dist/toolkit/guard.d.ts +27 -0
- package/dist/toolkit/guard.js +98 -0
- package/dist/toolkit/index.d.ts +48 -0
- package/dist/toolkit/index.js +82 -0
- package/dist/toolkit/meta.d.ts +36 -0
- package/dist/toolkit/meta.js +117 -0
- package/dist/toolkit/profiles.d.ts +13 -0
- package/dist/toolkit/profiles.js +25 -0
- package/dist/toolkit/record.d.ts +15 -0
- package/dist/toolkit/record.js +17 -0
- package/dist/tools/memoryTools.d.ts +95 -0
- package/dist/tools/memoryTools.js +179 -0
- package/dist/tools/sendMessage.d.ts +21 -0
- package/dist/tools/sendMessage.js +25 -0
- package/dist/types/pipeline.d.ts +7 -1
- package/dist/types/stage.d.ts +15 -0
- package/dist/ui/assets/AssistantRuntimeProvider-CsiPFhCT.js +9 -0
- package/dist/ui/assets/MarkdownText-D7tYaIFO.js +29 -0
- package/dist/ui/assets/ReadonlyStore-BfXqHykm.js +6 -0
- package/dist/ui/assets/Tooltip-DrpoRRNi.js +1 -0
- package/dist/ui/assets/chat-BbLukq9W.js +5 -0
- package/dist/ui/assets/chat-zIZU_bP1.css +2 -0
- package/dist/ui/assets/jsx-runtime-iVoFAvQ_.js +1 -0
- package/dist/ui/assets/{index-CIPbbBUi.css → main-BFo79krC.css} +1 -1
- package/dist/ui/assets/main-CijYMeM0.js +259 -0
- package/dist/ui/chat.html +25 -0
- package/dist/ui/index.html +6 -2
- package/dist/workshop/modelSettings.d.ts +2 -2
- package/dist/workshop/modelSettings.js +5 -11
- package/examples/README.md +1 -0
- package/examples/channels-smoke/README.md +23 -0
- package/examples/channels-smoke/channels-smoke.pipeline.yaml +11 -0
- package/examples/channels-smoke/channels-smoke.task.yaml +3 -0
- package/examples/channels-smoke/send-email.yaml +32 -0
- package/examples/channels-smoke/send-text.yaml +31 -0
- package/examples/github-release/README.md +1 -1
- package/examples/memory/README.md +99 -0
- package/examples/memory/desk-outage.task.yaml +15 -0
- package/examples/memory/desk-refund.task.yaml +15 -0
- package/examples/memory/desk-revisit-outage.task.yaml +11 -0
- package/examples/memory/desk-revisit.pipeline.yaml +123 -0
- package/examples/memory/desk.pipeline.yaml +192 -0
- package/examples/memory/file-card.pipeline.yaml +39 -0
- package/examples/memory/file-ledger.task.yaml +22 -0
- package/examples/memory/file-release.task.yaml +24 -0
- package/examples/memory/file-rich.pipeline.yaml +40 -0
- package/examples/memory/file-ticket.task.yaml +7 -0
- package/examples/memory/file-timeline.task.yaml +28 -0
- package/examples/memory/find-card.pipeline.yaml +46 -0
- package/examples/memory/find-ticket.task.yaml +7 -0
- package/examples/memory/missing.pipeline.yaml +16 -0
- package/examples/memory/recall.pipeline.yaml +24 -0
- package/examples/memory/recall.task.yaml +5 -0
- package/examples/memory/remember-ada.task.yaml +6 -0
- package/examples/memory/remember-grace.task.yaml +6 -0
- package/examples/memory/remember.pipeline.yaml +58 -0
- package/examples/memory/stageflow.yaml +12 -0
- package/examples/memory/update-card.pipeline.yaml +43 -0
- package/examples/memory/update-ledger.task.yaml +23 -0
- package/examples/memory/update-rich.pipeline.yaml +44 -0
- package/examples/memory/update-ticket.task.yaml +7 -0
- package/examples/stage-mcp/README.md +1 -1
- package/package.json +9 -3
- package/dist/ui/assets/index-BoFPkKFg.js +0 -256
|
@@ -0,0 +1,142 @@
|
|
|
1
|
+
import { CHANNEL_ERROR_INFO } from "../../channels/errorInfo.js";
|
|
2
|
+
import { ChannelError } from "../../channels/errors.js";
|
|
3
|
+
import { AgentError } from "../errors.js";
|
|
4
|
+
/** Waits before each automatic retry of a reply that definitely did not go out. */
|
|
5
|
+
export const RETRY_BACKOFF_MS = [5_000, 30_000, 120_000];
|
|
6
|
+
/** Sorts a send failure into "never left" (safe to retry when transient) and "may have arrived" (never resent on its own). */
|
|
7
|
+
export function classifySendFailure(error) {
|
|
8
|
+
if (error instanceof ChannelError) {
|
|
9
|
+
const info = CHANNEL_ERROR_INFO[error.code];
|
|
10
|
+
const unknown = error.code === "CHANNEL_SEND_OUTCOME_UNKNOWN";
|
|
11
|
+
return { kind: unknown ? "unknown" : "failed", retryable: !unknown && error.retryable, code: error.code, message: `${info.reason}${info.hint ? `. ${info.hint}` : ""}` };
|
|
12
|
+
}
|
|
13
|
+
const code = String(error?.code ?? "SEND_FAILED");
|
|
14
|
+
const message = (error instanceof Error ? error.message : String(error)).slice(0, 300);
|
|
15
|
+
// Something broke on this side before the send: nothing left, but retrying the same call will not fix it.
|
|
16
|
+
return { kind: "failed", retryable: false, code, message };
|
|
17
|
+
}
|
|
18
|
+
/**
|
|
19
|
+
* Gets an agent's reply to the person on the channel they wrote from. The turn only counts as succeeded once the
|
|
20
|
+
* reply is delivered: a definite failure is retried with backoff when it may be transient; an unknown outcome is never
|
|
21
|
+
* resent automatically (it could arrive twice); anything left over is a failed turn a person can resend or mark delivered.
|
|
22
|
+
*/
|
|
23
|
+
export class ReplyDelivery {
|
|
24
|
+
options;
|
|
25
|
+
timers = new Set();
|
|
26
|
+
sweeping;
|
|
27
|
+
constructor(options) {
|
|
28
|
+
this.options = options;
|
|
29
|
+
}
|
|
30
|
+
get backoff() {
|
|
31
|
+
return this.options.backoffMs ?? RETRY_BACKOFF_MS;
|
|
32
|
+
}
|
|
33
|
+
/** Record the reply and make the first attempt. Resolves with the delivery's state after that attempt. */
|
|
34
|
+
async deliver(input) {
|
|
35
|
+
const { deliveries } = this.options.service;
|
|
36
|
+
const existing = deliveries.get(input.turnId);
|
|
37
|
+
if (existing)
|
|
38
|
+
return existing;
|
|
39
|
+
deliveries.create(input);
|
|
40
|
+
return this.attempt(input.turnId, "first");
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* One send attempt. The right to send is claimed first, atomically (`pending`/`failed`/`unknown` -> `sending`), and
|
|
44
|
+
* the row is what is trusted, not any list the caller read earlier: an attempt that finds the delivery already
|
|
45
|
+
* claimed, sent or no longer due does nothing and returns it as it is. Overlapping sweeps and timers therefore
|
|
46
|
+
* cannot send the same reply twice.
|
|
47
|
+
*/
|
|
48
|
+
async attempt(turnId, kind) {
|
|
49
|
+
const { service } = this.options;
|
|
50
|
+
const automatic = kind !== "manual";
|
|
51
|
+
// Written before sending: a crash mid-send leaves "sending", which a restart turns into "unknown".
|
|
52
|
+
const current = service.deliveries.claim(turnId, kind);
|
|
53
|
+
if (!current)
|
|
54
|
+
return service.deliveries.get(turnId);
|
|
55
|
+
const attempts = current.attempts;
|
|
56
|
+
try {
|
|
57
|
+
await this.options.reply(current.connectionId, current.replyTo, current.text, `agent-reply:${current.sessionId}:${turnId}:${attempts}`);
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
const outcome = classifySendFailure(error);
|
|
61
|
+
const retry = automatic && outcome.kind === "failed" && outcome.retryable && attempts <= this.backoff.length;
|
|
62
|
+
const delay = this.backoff[attempts - 1];
|
|
63
|
+
const next = retry && delay !== undefined ? new Date(service.clockMs() + delay).toISOString() : null;
|
|
64
|
+
const saved = service.deliveries.patch(turnId, { state: outcome.kind, errorCode: outcome.code, errorMessage: outcome.message, retryable: next !== null, nextAttemptAt: next });
|
|
65
|
+
// While a retry is coming the turn stays as it was; when none is, the turn failed.
|
|
66
|
+
if (next === null)
|
|
67
|
+
service.setReplyDelivered(turnId, false);
|
|
68
|
+
else if (delay !== undefined)
|
|
69
|
+
this.later(delay);
|
|
70
|
+
this.options.log?.(`agent reply ${turnId} ${outcome.kind} (${outcome.code}) after attempt ${attempts}${next ? `, retrying at ${next}` : ""}`);
|
|
71
|
+
return saved;
|
|
72
|
+
}
|
|
73
|
+
const saved = service.deliveries.patch(turnId, { state: "delivered", errorCode: null, errorMessage: null, retryable: false, nextAttemptAt: null });
|
|
74
|
+
service.setReplyDelivered(turnId, true);
|
|
75
|
+
return saved;
|
|
76
|
+
}
|
|
77
|
+
later(delayMs) {
|
|
78
|
+
const timer = setTimeout(() => {
|
|
79
|
+
this.timers.delete(timer);
|
|
80
|
+
this.sweepInBackground();
|
|
81
|
+
}, delayMs + 50);
|
|
82
|
+
timer.unref?.();
|
|
83
|
+
this.timers.add(timer);
|
|
84
|
+
}
|
|
85
|
+
/** A timer-driven sweep has no caller to report to; a failure (a store closing mid-sweep) must not escape as an unhandled rejection. */
|
|
86
|
+
sweepInBackground() {
|
|
87
|
+
this.sweep().catch((error) => {
|
|
88
|
+
this.options.log?.(`agent reply sweep failed: ${error instanceof Error ? error.message : String(error)}`);
|
|
89
|
+
});
|
|
90
|
+
}
|
|
91
|
+
/** Retry what is due. Also run on a timer, so retries survive a restart. */
|
|
92
|
+
async sweep() {
|
|
93
|
+
const due = this.options.service.deliveries.due();
|
|
94
|
+
let attempted = 0;
|
|
95
|
+
for (const delivery of due) {
|
|
96
|
+
const before = this.options.service.deliveries.get(delivery.turnId);
|
|
97
|
+
// The list may be stale: another sweep or timer may have taken this one since it was read.
|
|
98
|
+
if (!before || before.state !== "failed")
|
|
99
|
+
continue;
|
|
100
|
+
const after = await this.attempt(delivery.turnId, "retry");
|
|
101
|
+
if (after.attempts > before.attempts)
|
|
102
|
+
attempted += 1;
|
|
103
|
+
}
|
|
104
|
+
return attempted;
|
|
105
|
+
}
|
|
106
|
+
/** On start: sends a restart cut off are now unknown; retries that were waiting are picked up by the sweep. */
|
|
107
|
+
start(intervalMs = 15_000) {
|
|
108
|
+
const settled = this.options.service.settleInterruptedDeliveries();
|
|
109
|
+
if (settled.length > 0)
|
|
110
|
+
this.options.log?.(`agent replies: ${settled.length} send(s) were cut off by a restart and need a person to resend or mark delivered`);
|
|
111
|
+
this.sweepInBackground();
|
|
112
|
+
this.sweeping = setInterval(() => this.sweepInBackground(), intervalMs);
|
|
113
|
+
this.sweeping.unref?.();
|
|
114
|
+
}
|
|
115
|
+
stop() {
|
|
116
|
+
if (this.sweeping)
|
|
117
|
+
clearInterval(this.sweeping);
|
|
118
|
+
for (const timer of this.timers)
|
|
119
|
+
clearTimeout(timer);
|
|
120
|
+
this.timers.clear();
|
|
121
|
+
}
|
|
122
|
+
/** A person resends a reply that failed or whose outcome was unknown. One attempt; no automatic retries after it. */
|
|
123
|
+
async resend(turnId) {
|
|
124
|
+
const current = this.options.service.deliveries.get(turnId);
|
|
125
|
+
if (!current)
|
|
126
|
+
throw new AgentError("agent.not_found", "This turn has no reply to send");
|
|
127
|
+
if (current.state !== "failed" && current.state !== "unknown")
|
|
128
|
+
throw new AgentError("agent.invalid", `The reply is ${current.state}; only a failed or unconfirmed reply can be resent`);
|
|
129
|
+
return this.attempt(turnId, "manual");
|
|
130
|
+
}
|
|
131
|
+
/** A person confirms the reply did arrive (for example after an unknown outcome). */
|
|
132
|
+
markDelivered(turnId) {
|
|
133
|
+
const current = this.options.service.deliveries.get(turnId);
|
|
134
|
+
if (!current)
|
|
135
|
+
throw new AgentError("agent.not_found", "This turn has no reply to send");
|
|
136
|
+
if (current.state === "delivered")
|
|
137
|
+
return current;
|
|
138
|
+
const saved = this.options.service.deliveries.patch(turnId, { state: "delivered", errorCode: null, errorMessage: null, retryable: false, nextAttemptAt: null });
|
|
139
|
+
this.options.service.setReplyDelivered(turnId, true);
|
|
140
|
+
return saved;
|
|
141
|
+
}
|
|
142
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import type { AgentInboundMessage } from "./port.js";
|
|
2
|
+
import type { AgentInboundPort } from "./port.js";
|
|
3
|
+
export type NormalizedAgentEmail = {
|
|
4
|
+
connectionId: string;
|
|
5
|
+
messageId: string;
|
|
6
|
+
threadId: string;
|
|
7
|
+
from: string;
|
|
8
|
+
subject: string;
|
|
9
|
+
text: string;
|
|
10
|
+
};
|
|
11
|
+
export declare function normalizeAgentEmail(input: NormalizedAgentEmail): AgentInboundMessage;
|
|
12
|
+
export type EmailDispatchInput = {
|
|
13
|
+
email: NormalizedAgentEmail;
|
|
14
|
+
isBound: boolean;
|
|
15
|
+
port: AgentInboundPort;
|
|
16
|
+
projectRoot: string;
|
|
17
|
+
onTrigger?: (email: NormalizedAgentEmail) => Promise<void>;
|
|
18
|
+
};
|
|
19
|
+
export declare function dispatchAgentEmail(input: EmailDispatchInput): Promise<{
|
|
20
|
+
routed: "agent";
|
|
21
|
+
result: Awaited<ReturnType<AgentInboundPort["receive"]>>;
|
|
22
|
+
} | {
|
|
23
|
+
routed: "trigger";
|
|
24
|
+
} | {
|
|
25
|
+
routed: "none";
|
|
26
|
+
}>;
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export function normalizeAgentEmail(input) {
|
|
2
|
+
return {
|
|
3
|
+
provider: "email",
|
|
4
|
+
connectionId: input.connectionId,
|
|
5
|
+
eventId: `email:${input.messageId}`,
|
|
6
|
+
threadId: input.threadId,
|
|
7
|
+
text: input.subject.trim().length > 0 ? `${input.subject}\n\n${input.text}` : input.text,
|
|
8
|
+
from: input.from,
|
|
9
|
+
};
|
|
10
|
+
}
|
|
11
|
+
export async function dispatchAgentEmail(input) {
|
|
12
|
+
if (input.onTrigger) {
|
|
13
|
+
await input.onTrigger(input.email);
|
|
14
|
+
}
|
|
15
|
+
if (!input.isBound) {
|
|
16
|
+
return { routed: input.onTrigger ? "trigger" : "none" };
|
|
17
|
+
}
|
|
18
|
+
const result = await input.port.receive(input.projectRoot, normalizeAgentEmail(input.email));
|
|
19
|
+
return { routed: "agent", result };
|
|
20
|
+
}
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a connection really talks to. Two connections can point at the same AgentMail inbox or the same Photon
|
|
3
|
+
* project; one stream and one agent may serve such a pair, so they share an identity. Other providers are
|
|
4
|
+
* identified by the connection itself.
|
|
5
|
+
*/
|
|
6
|
+
export declare function channelIdentity(connection: {
|
|
7
|
+
id: string;
|
|
8
|
+
provider: string;
|
|
9
|
+
config?: unknown;
|
|
10
|
+
credential?: {
|
|
11
|
+
secrets: Record<string, string>;
|
|
12
|
+
} | undefined;
|
|
13
|
+
}): string;
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* What a connection really talks to. Two connections can point at the same AgentMail inbox or the same Photon
|
|
3
|
+
* project; one stream and one agent may serve such a pair, so they share an identity. Other providers are
|
|
4
|
+
* identified by the connection itself.
|
|
5
|
+
*/
|
|
6
|
+
export function channelIdentity(connection) {
|
|
7
|
+
if (connection.provider === "agentmail") {
|
|
8
|
+
const config = (connection.config ?? {});
|
|
9
|
+
if (config.inbox)
|
|
10
|
+
return `agentmail:${config.region ?? "us"}:${config.inbox.trim().toLowerCase()}`;
|
|
11
|
+
}
|
|
12
|
+
if (connection.provider === "imessage") {
|
|
13
|
+
const project = connection.credential?.secrets.projectId;
|
|
14
|
+
if (project)
|
|
15
|
+
return `imessage:${project}`;
|
|
16
|
+
}
|
|
17
|
+
return `${connection.provider}:${connection.id}`;
|
|
18
|
+
}
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import { type ChannelListener, type ListenerDeps } from "./listener.js";
|
|
2
|
+
import type { AgentInboundMessage } from "./port.js";
|
|
3
|
+
/** What this listener reads of a Spectrum message. */
|
|
4
|
+
export type SpectrumInbound = {
|
|
5
|
+
id: string;
|
|
6
|
+
direction?: string;
|
|
7
|
+
content?: {
|
|
8
|
+
type?: string;
|
|
9
|
+
text?: string;
|
|
10
|
+
};
|
|
11
|
+
sender?: {
|
|
12
|
+
id?: string;
|
|
13
|
+
} | undefined;
|
|
14
|
+
space?: {
|
|
15
|
+
id?: string;
|
|
16
|
+
type?: string;
|
|
17
|
+
} | undefined;
|
|
18
|
+
};
|
|
19
|
+
export type SpectrumStream = {
|
|
20
|
+
messages: AsyncIterable<[unknown, SpectrumInbound]>;
|
|
21
|
+
stop(): Promise<void>;
|
|
22
|
+
};
|
|
23
|
+
export type IMessageTransport = {
|
|
24
|
+
open(): Promise<SpectrumStream>;
|
|
25
|
+
};
|
|
26
|
+
export type IMessageListenerConfig = {
|
|
27
|
+
connectionId: string;
|
|
28
|
+
};
|
|
29
|
+
/** The text and sender of a message the agent should answer, or undefined for everything else. */
|
|
30
|
+
export declare function toInbound(connectionId: string, message: SpectrumInbound): AgentInboundMessage | undefined;
|
|
31
|
+
/**
|
|
32
|
+
* Holds a Spectrum (Photon) stream open for an iMessage line. One conversation per sender, so with an
|
|
33
|
+
* always-on agent each person has one session that carries on after a failure. A dropped stream is
|
|
34
|
+
* reopened with backoff.
|
|
35
|
+
*/
|
|
36
|
+
export declare function createIMessageListener(config: IMessageListenerConfig, transport: IMessageTransport, deps: ListenerDeps): ChannelListener;
|
|
37
|
+
/** The real transport over the Spectrum SDK; the packages load on first use. */
|
|
38
|
+
export declare function sdkIMessageTransport(credentials: {
|
|
39
|
+
projectId: string;
|
|
40
|
+
projectSecret: string;
|
|
41
|
+
}, importer?: (specifier: string) => Promise<any>): IMessageTransport;
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
import { normalizeSender } from "./allowlist.js";
|
|
2
|
+
import { abortableSleep, reconnectDelay, StateTracker } from "./listener.js";
|
|
3
|
+
/** The text and sender of a message the agent should answer, or undefined for everything else. */
|
|
4
|
+
export function toInbound(connectionId, message) {
|
|
5
|
+
if (message.direction !== undefined && message.direction !== "inbound")
|
|
6
|
+
return undefined;
|
|
7
|
+
if (message.content?.type !== "text")
|
|
8
|
+
return undefined;
|
|
9
|
+
const text = message.content.text?.trim() ?? "";
|
|
10
|
+
const sender = message.sender?.id;
|
|
11
|
+
if (text.length === 0 || !sender)
|
|
12
|
+
return undefined;
|
|
13
|
+
// Group chats are out of scope: replying to one person's handle would answer the wrong audience.
|
|
14
|
+
if (message.space?.type === "group")
|
|
15
|
+
return undefined;
|
|
16
|
+
const from = normalizeSender(sender);
|
|
17
|
+
return { provider: "imessage", connectionId, eventId: `imessage:${message.id}`, threadId: from, text, from, replyTo: from };
|
|
18
|
+
}
|
|
19
|
+
/**
|
|
20
|
+
* Holds a Spectrum (Photon) stream open for an iMessage line. One conversation per sender, so with an
|
|
21
|
+
* always-on agent each person has one session that carries on after a failure. A dropped stream is
|
|
22
|
+
* reopened with backoff.
|
|
23
|
+
*/
|
|
24
|
+
export function createIMessageListener(config, transport, deps) {
|
|
25
|
+
const now = deps.now ?? Date.now;
|
|
26
|
+
const sleep = deps.sleep ?? abortableSleep;
|
|
27
|
+
const tracker = new StateTracker(now);
|
|
28
|
+
const abort = new AbortController();
|
|
29
|
+
let loop;
|
|
30
|
+
let stream;
|
|
31
|
+
async function session() {
|
|
32
|
+
stream = await transport.open();
|
|
33
|
+
tracker.set("connected");
|
|
34
|
+
for await (const [, message] of stream.messages) {
|
|
35
|
+
if (abort.signal.aborted)
|
|
36
|
+
return;
|
|
37
|
+
const inbound = toInbound(config.connectionId, message);
|
|
38
|
+
if (!inbound)
|
|
39
|
+
continue;
|
|
40
|
+
tracker.received();
|
|
41
|
+
await deps.onMessage(inbound).catch((error) => deps.log(`imessage ${config.connectionId}: delivery failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
42
|
+
}
|
|
43
|
+
if (!abort.signal.aborted)
|
|
44
|
+
throw new Error("stream ended");
|
|
45
|
+
}
|
|
46
|
+
async function run() {
|
|
47
|
+
let attempt = 0;
|
|
48
|
+
while (!abort.signal.aborted) {
|
|
49
|
+
try {
|
|
50
|
+
await session();
|
|
51
|
+
attempt = 0;
|
|
52
|
+
}
|
|
53
|
+
catch (error) {
|
|
54
|
+
if (abort.signal.aborted)
|
|
55
|
+
break;
|
|
56
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
57
|
+
tracker.set(attempt === 0 ? "reconnecting" : "failed", message);
|
|
58
|
+
deps.log(`imessage ${config.connectionId}: ${message}; retrying`);
|
|
59
|
+
attempt += 1;
|
|
60
|
+
await sleep(reconnectDelay(attempt - 1), abort.signal);
|
|
61
|
+
}
|
|
62
|
+
finally {
|
|
63
|
+
await stream?.stop().catch(() => undefined);
|
|
64
|
+
stream = undefined;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
tracker.set("stopped");
|
|
68
|
+
}
|
|
69
|
+
return {
|
|
70
|
+
async start() {
|
|
71
|
+
loop = run();
|
|
72
|
+
await Promise.resolve();
|
|
73
|
+
},
|
|
74
|
+
async stop() {
|
|
75
|
+
abort.abort();
|
|
76
|
+
await stream?.stop().catch(() => undefined);
|
|
77
|
+
await loop;
|
|
78
|
+
},
|
|
79
|
+
state: () => tracker.get(),
|
|
80
|
+
};
|
|
81
|
+
}
|
|
82
|
+
/** The real transport over the Spectrum SDK; the packages load on first use. */
|
|
83
|
+
export function sdkIMessageTransport(credentials, importer = (s) => import(s)) {
|
|
84
|
+
return {
|
|
85
|
+
async open() {
|
|
86
|
+
const core = await importer("@spectrum-ts/core");
|
|
87
|
+
const module = await importer("@spectrum-ts/imessage");
|
|
88
|
+
const app = await core.Spectrum({ projectId: credentials.projectId, projectSecret: credentials.projectSecret, providers: [module.imessage.config()] });
|
|
89
|
+
return { messages: app.messages, stop: () => app.stop() };
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
import type { ContactInfo } from "../../channels/types.js";
|
|
2
|
+
import { ReplyDelivery } from "./delivery.js";
|
|
3
|
+
import type { AgentChatController } from "../chatTurn.js";
|
|
4
|
+
import type { AgentService } from "../service.js";
|
|
5
|
+
import type { AgentInboundMessage } from "./port.js";
|
|
6
|
+
/** What the router needs from the Host's connections: who a connection is, and how to answer on it. */
|
|
7
|
+
export type InboundConnections = {
|
|
8
|
+
find(connectionId: string): {
|
|
9
|
+
provider: string;
|
|
10
|
+
secret: string | undefined;
|
|
11
|
+
} | undefined;
|
|
12
|
+
reply(connectionId: string, to: string, text: string, operationKey: string): Promise<void>;
|
|
13
|
+
/** How people reach the connection (the number to text), when the provider can tell. */
|
|
14
|
+
contact?(connectionId: string): Promise<ContactInfo | undefined>;
|
|
15
|
+
/** The connection with its secrets, for providers the Host listens on itself (a stream instead of a webhook). */
|
|
16
|
+
resolve?(connectionId: string): ListenerConnection | undefined;
|
|
17
|
+
};
|
|
18
|
+
export type ListenerConnection = {
|
|
19
|
+
id: string;
|
|
20
|
+
provider: string;
|
|
21
|
+
config: unknown;
|
|
22
|
+
secrets: Record<string, string>;
|
|
23
|
+
credential?: {
|
|
24
|
+
secrets: Record<string, string>;
|
|
25
|
+
} | undefined;
|
|
26
|
+
};
|
|
27
|
+
export type InboundResponse = {
|
|
28
|
+
status: number;
|
|
29
|
+
body?: unknown;
|
|
30
|
+
};
|
|
31
|
+
type Headers = Record<string, string | undefined>;
|
|
32
|
+
/**
|
|
33
|
+
* Receives provider webhooks for agents. A request is accepted only when the connection has an
|
|
34
|
+
* inbound secret and the provider's signature verifies; everything else is refused without
|
|
35
|
+
* saying why. Replies go back on the same connection to the sender, once per turn.
|
|
36
|
+
*/
|
|
37
|
+
export declare class AgentInboundRouter {
|
|
38
|
+
private readonly options;
|
|
39
|
+
private readonly limiter;
|
|
40
|
+
/** Sends each reply back and keeps track of whether it got through. */
|
|
41
|
+
readonly delivery: ReplyDelivery;
|
|
42
|
+
constructor(options: {
|
|
43
|
+
service: AgentService;
|
|
44
|
+
chat: Pick<AgentChatController, "send">;
|
|
45
|
+
connections: InboundConnections;
|
|
46
|
+
projectRoot: string;
|
|
47
|
+
logError?: (message: string) => void;
|
|
48
|
+
senderLimitPerMinute?: number;
|
|
49
|
+
delivery?: ReplyDelivery;
|
|
50
|
+
});
|
|
51
|
+
handle(connectionId: string, headers: Headers, rawBody: string): Promise<InboundResponse>;
|
|
52
|
+
/**
|
|
53
|
+
* A message from any source (a webhook or a stream the Host listens on): enforce the sender allow-list and
|
|
54
|
+
* rate limit, route it to its session and answer on the same connection.
|
|
55
|
+
*/
|
|
56
|
+
deliver(message: AgentInboundMessage): Promise<InboundResponse>;
|
|
57
|
+
private verify;
|
|
58
|
+
private parse;
|
|
59
|
+
/** After a restart: retry the turns it cut off on always-on iMessage agents, answering the same sender once. */
|
|
60
|
+
resumeInterrupted(): Promise<number>;
|
|
61
|
+
private converse;
|
|
62
|
+
}
|
|
63
|
+
export {};
|
|
@@ -0,0 +1,143 @@
|
|
|
1
|
+
import { isAgentError } from "../errors.js";
|
|
2
|
+
import { ReplyDelivery } from "./delivery.js";
|
|
3
|
+
import { SlidingWindowLimiter } from "../webChat/rateLimit.js";
|
|
4
|
+
import { parseAgentMailInbound, verifyAgentMailSignature } from "./agentmail.js";
|
|
5
|
+
import { parseSlackInbound, verifySlackSignature } from "./slack.js";
|
|
6
|
+
import { parseTelegramInbound, verifyTelegramSecret } from "./telegram.js";
|
|
7
|
+
const MAX_REPLY_CHARS = 3500;
|
|
8
|
+
/** Messages one sender may send an agent per minute before the rest are ignored. */
|
|
9
|
+
const SENDER_LIMIT_PER_MINUTE = 20;
|
|
10
|
+
/**
|
|
11
|
+
* Receives provider webhooks for agents. A request is accepted only when the connection has an
|
|
12
|
+
* inbound secret and the provider's signature verifies; everything else is refused without
|
|
13
|
+
* saying why. Replies go back on the same connection to the sender, once per turn.
|
|
14
|
+
*/
|
|
15
|
+
export class AgentInboundRouter {
|
|
16
|
+
options;
|
|
17
|
+
limiter = new SlidingWindowLimiter();
|
|
18
|
+
/** Sends each reply back and keeps track of whether it got through. */
|
|
19
|
+
delivery;
|
|
20
|
+
constructor(options) {
|
|
21
|
+
this.options = options;
|
|
22
|
+
this.delivery =
|
|
23
|
+
options.delivery ??
|
|
24
|
+
new ReplyDelivery({
|
|
25
|
+
service: options.service,
|
|
26
|
+
reply: (connectionId, to, text, key) => options.connections.reply(connectionId, to, text, key),
|
|
27
|
+
...(options.logError ? { log: options.logError } : {}),
|
|
28
|
+
});
|
|
29
|
+
}
|
|
30
|
+
async handle(connectionId, headers, rawBody) {
|
|
31
|
+
const connection = this.options.connections.find(connectionId);
|
|
32
|
+
if (!connection || connection.secret === undefined)
|
|
33
|
+
return { status: 404 };
|
|
34
|
+
const verified = this.verify(connection.provider, connection.secret, connectionId, headers, rawBody);
|
|
35
|
+
if (!verified)
|
|
36
|
+
return { status: 401 };
|
|
37
|
+
let payload;
|
|
38
|
+
try {
|
|
39
|
+
payload = JSON.parse(rawBody);
|
|
40
|
+
}
|
|
41
|
+
catch {
|
|
42
|
+
return { status: 400 };
|
|
43
|
+
}
|
|
44
|
+
if (connection.provider === "slack" && payload?.type === "url_verification") {
|
|
45
|
+
return { status: 200, body: { challenge: payload.challenge } };
|
|
46
|
+
}
|
|
47
|
+
const parsed = this.parse(connection.provider, connectionId, connection.secret, headers, payload);
|
|
48
|
+
if (!parsed)
|
|
49
|
+
return { status: 400 };
|
|
50
|
+
if ("ignored" in parsed)
|
|
51
|
+
return { status: 200, body: { ignored: true } };
|
|
52
|
+
return this.deliver(parsed.message);
|
|
53
|
+
}
|
|
54
|
+
/**
|
|
55
|
+
* A message from any source (a webhook or a stream the Host listens on): enforce the sender allow-list and
|
|
56
|
+
* rate limit, route it to its session and answer on the same connection.
|
|
57
|
+
*/
|
|
58
|
+
async deliver(message) {
|
|
59
|
+
const limit = this.limiter.take(`${message.connectionId}\0${message.from ?? ""}`, this.options.senderLimitPerMinute ?? SENDER_LIMIT_PER_MINUTE, Date.now());
|
|
60
|
+
if (!limit.ok) {
|
|
61
|
+
this.options.logError?.(`agent inbound ${message.provider}: sender over the rate limit, message ignored`);
|
|
62
|
+
return { status: 200, body: { ignored: true } };
|
|
63
|
+
}
|
|
64
|
+
let routed;
|
|
65
|
+
try {
|
|
66
|
+
routed = await this.options.service.receive(this.options.projectRoot, message);
|
|
67
|
+
}
|
|
68
|
+
catch (error) {
|
|
69
|
+
// An unbound, disabled or not-allowed sender looks the same as success to the sender; nothing is retried.
|
|
70
|
+
if (isAgentError(error))
|
|
71
|
+
return { status: 200, body: { ignored: true } };
|
|
72
|
+
throw error;
|
|
73
|
+
}
|
|
74
|
+
if (routed.duplicate)
|
|
75
|
+
return { status: 200, body: { duplicate: true } };
|
|
76
|
+
void this.converse(routed, message);
|
|
77
|
+
return { status: 200, body: { ok: true } };
|
|
78
|
+
}
|
|
79
|
+
verify(provider, secret, connectionId, headers, rawBody) {
|
|
80
|
+
if (provider === "slack") {
|
|
81
|
+
return verifySlackSignature({ connectionId, signingSecret: secret }, { ...(headers["x-slack-signature"] ? { signature: headers["x-slack-signature"] } : {}), ...(headers["x-slack-request-timestamp"] ? { requestTimestamp: headers["x-slack-request-timestamp"] } : {}) }, rawBody).ok;
|
|
82
|
+
}
|
|
83
|
+
if (provider === "telegram")
|
|
84
|
+
return verifyTelegramSecret({ connectionId, secretToken: secret }, headers["x-telegram-bot-api-secret-token"]).ok;
|
|
85
|
+
if (provider === "agentmail")
|
|
86
|
+
return verifyAgentMailSignature({ connectionId, webhookSecret: secret }, headers["x-agentmail-signature"], rawBody).ok;
|
|
87
|
+
return false;
|
|
88
|
+
}
|
|
89
|
+
parse(provider, connectionId, secret, _headers, payload) {
|
|
90
|
+
const result = provider === "slack"
|
|
91
|
+
? parseSlackInbound({ connectionId, signingSecret: secret }, payload)
|
|
92
|
+
: provider === "telegram"
|
|
93
|
+
? parseTelegramInbound({ connectionId, secretToken: secret }, payload)
|
|
94
|
+
: parseAgentMailInbound({ connectionId, webhookSecret: secret }, payload);
|
|
95
|
+
if (!result.ok)
|
|
96
|
+
return undefined;
|
|
97
|
+
if ("ignored" in result)
|
|
98
|
+
return { ignored: true };
|
|
99
|
+
return { message: result.message };
|
|
100
|
+
}
|
|
101
|
+
/** After a restart: retry the turns it cut off on always-on iMessage agents, answering the same sender once. */
|
|
102
|
+
async resumeInterrupted() {
|
|
103
|
+
const turns = await this.options.service.resumableTurns(this.options.projectRoot);
|
|
104
|
+
for (const turn of turns) {
|
|
105
|
+
const message = { provider: turn.provider, connectionId: turn.connectionId, eventId: `resume:${turn.turnId}`, threadId: turn.threadId, text: turn.text, from: turn.from, replyTo: turn.from };
|
|
106
|
+
void this.converse({ agentId: turn.agentId, sessionId: turn.sessionId }, message, turn.turnId);
|
|
107
|
+
}
|
|
108
|
+
return turns.length;
|
|
109
|
+
}
|
|
110
|
+
async converse(routed, message, resumeTurnId) {
|
|
111
|
+
const { chat, connections, logError } = this.options;
|
|
112
|
+
try {
|
|
113
|
+
const detail = await chat.send({
|
|
114
|
+
projectRoot: this.options.projectRoot,
|
|
115
|
+
agentId: routed.agentId,
|
|
116
|
+
sessionId: routed.sessionId,
|
|
117
|
+
text: message.text,
|
|
118
|
+
author: message.from ?? null,
|
|
119
|
+
source: message.provider,
|
|
120
|
+
...(resumeTurnId ? { resumeTurnId } : {}),
|
|
121
|
+
});
|
|
122
|
+
const turn = detail.turns.at(-1);
|
|
123
|
+
const reply = [...detail.messages].reverse().find((m) => m.role === "assistant" && m.turnId === turn?.id);
|
|
124
|
+
if (!turn || !reply || !message.replyTo)
|
|
125
|
+
return;
|
|
126
|
+
// The turn only counts as succeeded once this reaches the person; failures are retried or left for a person.
|
|
127
|
+
await this.delivery.deliver({
|
|
128
|
+
turnId: turn.id,
|
|
129
|
+
sessionId: routed.sessionId,
|
|
130
|
+
connectionId: message.connectionId,
|
|
131
|
+
provider: message.provider,
|
|
132
|
+
replyTo: message.replyTo,
|
|
133
|
+
text: reply.text.slice(0, MAX_REPLY_CHARS),
|
|
134
|
+
});
|
|
135
|
+
}
|
|
136
|
+
catch (error) {
|
|
137
|
+
if (isAgentError(error) && error.code === "agent.limit_reached" && message.replyTo) {
|
|
138
|
+
await connections.reply(message.connectionId, message.replyTo, error.message, `agent-limit:${routed.sessionId}:${message.eventId}`).catch(() => undefined);
|
|
139
|
+
}
|
|
140
|
+
logError?.(`agent inbound ${message.provider} turn failed: ${isAgentError(error) ? `${error.code}${error.message !== error.code ? ` (${error.message})` : ""}` : error instanceof Error ? error.message : String(error)}`);
|
|
141
|
+
}
|
|
142
|
+
}
|
|
143
|
+
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import type { AgentInboundMessage } from "./port.js";
|
|
2
|
+
/** A stream the Host holds open to a provider so messages arrive without a public URL. */
|
|
3
|
+
export type ChannelListener = {
|
|
4
|
+
/** Resolves once the first connection attempt has been made; it keeps itself connected until `stop`. */
|
|
5
|
+
start(): Promise<void>;
|
|
6
|
+
stop(): Promise<void>;
|
|
7
|
+
state(): ListenerState;
|
|
8
|
+
};
|
|
9
|
+
export type ListenerState = {
|
|
10
|
+
status: "starting" | "connected" | "reconnecting" | "failed" | "stopped";
|
|
11
|
+
since: string;
|
|
12
|
+
/** Last failure, never a secret. */
|
|
13
|
+
error?: string;
|
|
14
|
+
/** Messages handed to the agents since the listener started. */
|
|
15
|
+
received: number;
|
|
16
|
+
};
|
|
17
|
+
export type ListenerDeps = {
|
|
18
|
+
onMessage(message: AgentInboundMessage): Promise<void>;
|
|
19
|
+
log(message: string): void;
|
|
20
|
+
now?: () => number;
|
|
21
|
+
/** Test seam: waits between reconnect attempts. */
|
|
22
|
+
sleep?: (ms: number, signal: AbortSignal) => Promise<void>;
|
|
23
|
+
};
|
|
24
|
+
export declare const RECONNECT_MIN_MS = 2000;
|
|
25
|
+
export declare const RECONNECT_MAX_MS = 60000;
|
|
26
|
+
export declare function reconnectDelay(attempt: number): number;
|
|
27
|
+
export declare function abortableSleep(ms: number, signal: AbortSignal): Promise<void>;
|
|
28
|
+
/** Tracks a listener's state for the console. */
|
|
29
|
+
export declare class StateTracker {
|
|
30
|
+
private readonly now;
|
|
31
|
+
private current;
|
|
32
|
+
constructor(now?: () => number);
|
|
33
|
+
set(status: ListenerState["status"], error?: string): void;
|
|
34
|
+
received(): void;
|
|
35
|
+
get(): ListenerState;
|
|
36
|
+
}
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
export const RECONNECT_MIN_MS = 2_000;
|
|
2
|
+
export const RECONNECT_MAX_MS = 60_000;
|
|
3
|
+
export function reconnectDelay(attempt) {
|
|
4
|
+
return Math.min(RECONNECT_MAX_MS, RECONNECT_MIN_MS * 2 ** Math.min(attempt, 10));
|
|
5
|
+
}
|
|
6
|
+
export function abortableSleep(ms, signal) {
|
|
7
|
+
return new Promise((resolve) => {
|
|
8
|
+
if (signal.aborted)
|
|
9
|
+
return resolve();
|
|
10
|
+
const timer = setTimeout(done, ms);
|
|
11
|
+
function done() {
|
|
12
|
+
clearTimeout(timer);
|
|
13
|
+
signal.removeEventListener("abort", done);
|
|
14
|
+
resolve();
|
|
15
|
+
}
|
|
16
|
+
signal.addEventListener("abort", done, { once: true });
|
|
17
|
+
});
|
|
18
|
+
}
|
|
19
|
+
/** Tracks a listener's state for the console. */
|
|
20
|
+
export class StateTracker {
|
|
21
|
+
now;
|
|
22
|
+
current;
|
|
23
|
+
constructor(now = Date.now) {
|
|
24
|
+
this.now = now;
|
|
25
|
+
this.current = { status: "starting", since: new Date(now()).toISOString(), received: 0 };
|
|
26
|
+
}
|
|
27
|
+
set(status, error) {
|
|
28
|
+
this.current = { ...this.current, status, since: new Date(this.now()).toISOString(), ...(error !== undefined ? { error } : {}) };
|
|
29
|
+
if (error === undefined && status !== "failed" && status !== "reconnecting")
|
|
30
|
+
delete this.current.error;
|
|
31
|
+
}
|
|
32
|
+
received() {
|
|
33
|
+
this.current = { ...this.current, received: this.current.received + 1 };
|
|
34
|
+
}
|
|
35
|
+
get() {
|
|
36
|
+
return { ...this.current };
|
|
37
|
+
}
|
|
38
|
+
}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AgentInboundMessage } from "./port.js";
|
|
2
|
+
export type PhotonInboundConfig = {
|
|
3
|
+
connectionId: string;
|
|
4
|
+
webhookSecret: string;
|
|
5
|
+
};
|
|
6
|
+
export type PhotonParseResult = {
|
|
7
|
+
ok: true;
|
|
8
|
+
message: AgentInboundMessage;
|
|
9
|
+
} | {
|
|
10
|
+
ok: false;
|
|
11
|
+
code: "agent.signature_invalid" | "agent.invalid";
|
|
12
|
+
};
|
|
13
|
+
export declare function verifyPhotonSignature(config: PhotonInboundConfig, signatureHeader: string | undefined, rawBody: string): {
|
|
14
|
+
ok: true;
|
|
15
|
+
} | {
|
|
16
|
+
ok: false;
|
|
17
|
+
code: "agent.signature_invalid";
|
|
18
|
+
};
|
|
19
|
+
export declare function parsePhotonInbound(config: PhotonInboundConfig, payload: unknown): PhotonParseResult;
|