stageflow 0.36.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 +6 -0
- package/dist/agent/piAdapter.d.ts +9 -1
- package/dist/agent/piAdapter.js +21 -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 +5 -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/providers/agentmail.js +14 -1
- package/dist/channels/providers/photonImessage.js +36 -0
- package/dist/channels/providers/slack.js +2 -1
- package/dist/channels/providers/telegram.js +2 -1
- package/dist/channels/types.d.ts +27 -0
- package/dist/cli/agentsCommand.d.ts +14 -0
- package/dist/cli/agentsCommand.js +291 -0
- package/dist/cli.js +9 -0
- package/dist/config/browseCatalog.js +4 -11
- package/dist/config/draftPackage.d.ts +1 -0
- package/dist/config/draftPackage.js +4 -0
- package/dist/config/hostConfig.js +7 -0
- package/dist/config/loadAgent.d.ts +266 -0
- package/dist/config/loadAgent.js +374 -0
- package/dist/config/loadPipeline.js +5 -2
- 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 +2 -0
- package/dist/config/mergePipelineIncludes.js +25 -0
- package/dist/config/resolveStageMcpServers.d.ts +35 -3
- package/dist/config/resolveStageMcpServers.js +124 -9
- package/dist/config/validateCatalog.d.ts +1 -1
- package/dist/config/validateCatalog.js +22 -1
- package/dist/mcp/agentTools.d.ts +8 -0
- package/dist/mcp/agentTools.js +132 -0
- package/dist/mcp/catalogTools.js +6 -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/pipelinePreflight.js +1 -0
- 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/stageAttemptBootstrap.js +15 -1
- package/dist/runtime/stageEnvironment.d.ts +2 -0
- package/dist/runtime/stageEnvironment.js +6 -1
- package/dist/runtime/stageProcessLauncher.js +23 -0
- 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 +8 -0
- package/dist/server/bootstrap.js +36 -10
- package/dist/server/controlToken.js +8 -0
- package/dist/server/http.d.ts +8 -1
- package/dist/server/http.js +172 -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/readEmail.d.ts +3 -3
- package/dist/types/pipeline.d.ts +3 -0
- package/dist/types/stage.d.ts +2 -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-Clv4sJpU.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 +12 -1
- 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 +2 -1
- package/dist/ui/assets/index-BOhqnPNg.js +0 -260
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import type { AgentInboundMessage } from "./port.js";
|
|
2
|
+
export type AgentMailInboundConfig = {
|
|
3
|
+
connectionId: string;
|
|
4
|
+
webhookSecret: string;
|
|
5
|
+
};
|
|
6
|
+
export type AgentMailParseResult = {
|
|
7
|
+
ok: true;
|
|
8
|
+
message: AgentInboundMessage;
|
|
9
|
+
} | {
|
|
10
|
+
ok: false;
|
|
11
|
+
code: "agent.signature_invalid" | "agent.invalid";
|
|
12
|
+
};
|
|
13
|
+
export declare function verifyAgentMailSignature(config: AgentMailInboundConfig, signatureHeader: string | undefined, rawBody: string): {
|
|
14
|
+
ok: true;
|
|
15
|
+
} | {
|
|
16
|
+
ok: false;
|
|
17
|
+
code: "agent.signature_invalid";
|
|
18
|
+
};
|
|
19
|
+
export declare function parseAgentMailInbound(config: AgentMailInboundConfig, payload: unknown): AgentMailParseResult;
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import { createHmac, timingSafeEqual } from "node:crypto";
|
|
2
|
+
export function verifyAgentMailSignature(config, signatureHeader, rawBody) {
|
|
3
|
+
if (signatureHeader === undefined)
|
|
4
|
+
return { ok: false, code: "agent.signature_invalid" };
|
|
5
|
+
const expected = createHmac("sha256", config.webhookSecret).update(rawBody).digest("hex");
|
|
6
|
+
const left = Buffer.from(expected);
|
|
7
|
+
const right = Buffer.from(signatureHeader);
|
|
8
|
+
if (left.length !== right.length || !timingSafeEqual(left, right)) {
|
|
9
|
+
return { ok: false, code: "agent.signature_invalid" };
|
|
10
|
+
}
|
|
11
|
+
return { ok: true };
|
|
12
|
+
}
|
|
13
|
+
export function parseAgentMailInbound(config, payload) {
|
|
14
|
+
if (typeof payload !== "object" || payload === null)
|
|
15
|
+
return { ok: false, code: "agent.invalid" };
|
|
16
|
+
const record = payload;
|
|
17
|
+
const eventId = typeof record.id === "string" ? record.id : undefined;
|
|
18
|
+
const threadId = typeof record.thread_id === "string"
|
|
19
|
+
? record.thread_id
|
|
20
|
+
: typeof record.inbox_id === "string"
|
|
21
|
+
? record.inbox_id
|
|
22
|
+
: undefined;
|
|
23
|
+
const text = typeof record.text === "string"
|
|
24
|
+
? record.text.trim()
|
|
25
|
+
: typeof record.body === "string"
|
|
26
|
+
? record.body.trim()
|
|
27
|
+
: "";
|
|
28
|
+
if (eventId === undefined || threadId === undefined || text.length === 0) {
|
|
29
|
+
return { ok: false, code: "agent.invalid" };
|
|
30
|
+
}
|
|
31
|
+
const from = typeof record.from === "string" ? record.from : undefined;
|
|
32
|
+
// Replies go to a message id (AgentMail's reply endpoint), never to the sender's address.
|
|
33
|
+
const replyTo = typeof record.message_id === "string" ? record.message_id : eventId;
|
|
34
|
+
return {
|
|
35
|
+
ok: true,
|
|
36
|
+
message: {
|
|
37
|
+
provider: "agentmail",
|
|
38
|
+
connectionId: config.connectionId,
|
|
39
|
+
eventId: `agentmail:${eventId}`,
|
|
40
|
+
threadId,
|
|
41
|
+
text,
|
|
42
|
+
replyTo,
|
|
43
|
+
...(from !== undefined ? { from } : {}),
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
@@ -0,0 +1,59 @@
|
|
|
1
|
+
import type { AgentInboundMessage } from "./port.js";
|
|
2
|
+
import { type ChannelListener, type ListenerDeps } from "./listener.js";
|
|
3
|
+
/** The slice of the AgentMail SDK socket this listener uses; tests supply a fake. */
|
|
4
|
+
export type AgentMailSocket = {
|
|
5
|
+
on(event: "open" | "message" | "close" | "error", callback: (payload?: any) => void): void;
|
|
6
|
+
sendSubscribe(message: {
|
|
7
|
+
type: "subscribe";
|
|
8
|
+
inboxIds: string[];
|
|
9
|
+
eventTypes: string[];
|
|
10
|
+
}): void;
|
|
11
|
+
waitForOpen(): Promise<unknown>;
|
|
12
|
+
close(): void;
|
|
13
|
+
};
|
|
14
|
+
export type AgentMailMessage = {
|
|
15
|
+
inboxId: string;
|
|
16
|
+
threadId: string;
|
|
17
|
+
messageId: string;
|
|
18
|
+
labels?: string[];
|
|
19
|
+
timestamp?: string | Date;
|
|
20
|
+
from: string;
|
|
21
|
+
subject?: string;
|
|
22
|
+
text?: string;
|
|
23
|
+
html?: string;
|
|
24
|
+
extractedText?: string;
|
|
25
|
+
headers?: Record<string, string>;
|
|
26
|
+
};
|
|
27
|
+
export type AgentMailTransport = {
|
|
28
|
+
connect(): Promise<AgentMailSocket>;
|
|
29
|
+
/** Mail that arrived while the Host was not listening. */
|
|
30
|
+
recent(inbox: string, since: Date): Promise<AgentMailMessage[]>;
|
|
31
|
+
};
|
|
32
|
+
export type AgentMailListenerConfig = {
|
|
33
|
+
connectionId: string;
|
|
34
|
+
inbox: string;
|
|
35
|
+
};
|
|
36
|
+
/** The bare address of "Name <a@b.com>", lowercased. */
|
|
37
|
+
export declare function addressOf(from: string): string;
|
|
38
|
+
/**
|
|
39
|
+
* Why an email must not get an answer, or undefined when it should. Keeps the agent out of mail loops:
|
|
40
|
+
* its own mail, bounces, automatic replies and no-reply senders.
|
|
41
|
+
*/
|
|
42
|
+
export declare function ignoreReason(message: AgentMailMessage, inbox: string): string | undefined;
|
|
43
|
+
/** What the agent reads: the new text of the email (no quoted history), led by the subject on a first message. */
|
|
44
|
+
export declare function bodyOf(message: AgentMailMessage): string;
|
|
45
|
+
export declare function toInbound(connectionId: string, message: AgentMailMessage): AgentInboundMessage | undefined;
|
|
46
|
+
/** Minutes of missed mail answered after a reconnect or restart. */
|
|
47
|
+
export declare const CATCHUP_MINUTES = 15;
|
|
48
|
+
/**
|
|
49
|
+
* Holds an AgentMail WebSocket open for one inbox. A dropped connection is retried with backoff and, once back,
|
|
50
|
+
* mail that arrived meanwhile is answered too (deduped by message id). Only `message.received` events count:
|
|
51
|
+
* spam, blocked and unauthenticated (spoofed sender) mail uses other event types and never reaches an agent.
|
|
52
|
+
*/
|
|
53
|
+
export declare function createAgentMailListener(config: AgentMailListenerConfig, transport: AgentMailTransport, deps: ListenerDeps): ChannelListener;
|
|
54
|
+
/** The real transport over the AgentMail SDK. The SDK loads on first use, so a Host without AgentMail never needs it. */
|
|
55
|
+
export declare function sdkAgentMailTransport(options: {
|
|
56
|
+
apiKey: string;
|
|
57
|
+
region: "us" | "eu";
|
|
58
|
+
inbox: string;
|
|
59
|
+
}): AgentMailTransport;
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
import { abortableSleep, reconnectDelay, StateTracker } from "./listener.js";
|
|
2
|
+
const NOREPLY_LOCALPART = /^(mailer-daemon|postmaster|no-?reply|do-?not-?reply|bounce[sd]?|notifications?)([+._-].*)?$/i;
|
|
3
|
+
/** The bare address of "Name <a@b.com>", lowercased. */
|
|
4
|
+
export function addressOf(from) {
|
|
5
|
+
const angle = /<([^<>]+)>/.exec(from);
|
|
6
|
+
return (angle ? angle[1] : from).trim().toLowerCase();
|
|
7
|
+
}
|
|
8
|
+
function stripHtml(html) {
|
|
9
|
+
return html
|
|
10
|
+
.replace(/<(script|style)[\s\S]*?<\/\1>/gi, "")
|
|
11
|
+
.replace(/<br\s*\/?>|<\/p>|<\/div>/gi, "\n")
|
|
12
|
+
.replace(/<[^>]+>/g, "")
|
|
13
|
+
.replace(/ /g, " ")
|
|
14
|
+
.replace(/&/g, "&")
|
|
15
|
+
.replace(/</g, "<")
|
|
16
|
+
.replace(/>/g, ">")
|
|
17
|
+
.replace(/\n{3,}/g, "\n\n")
|
|
18
|
+
.trim();
|
|
19
|
+
}
|
|
20
|
+
/**
|
|
21
|
+
* Why an email must not get an answer, or undefined when it should. Keeps the agent out of mail loops:
|
|
22
|
+
* its own mail, bounces, automatic replies and no-reply senders.
|
|
23
|
+
*/
|
|
24
|
+
export function ignoreReason(message, inbox) {
|
|
25
|
+
const labels = message.labels ?? [];
|
|
26
|
+
if (labels.includes("sent") || (labels.length > 0 && !labels.includes("received")))
|
|
27
|
+
return "not received mail";
|
|
28
|
+
const sender = addressOf(message.from);
|
|
29
|
+
if (sender === inbox.toLowerCase())
|
|
30
|
+
return "from the agent's own inbox";
|
|
31
|
+
const local = sender.split("@")[0] ?? "";
|
|
32
|
+
if (NOREPLY_LOCALPART.test(local))
|
|
33
|
+
return "from a no-reply or system sender";
|
|
34
|
+
const headers = Object.fromEntries(Object.entries(message.headers ?? {}).map(([key, value]) => [key.toLowerCase(), String(value).toLowerCase()]));
|
|
35
|
+
if (headers["auto-submitted"] !== undefined && headers["auto-submitted"] !== "no")
|
|
36
|
+
return "automatic reply";
|
|
37
|
+
if (headers["x-auto-response-suppress"] !== undefined)
|
|
38
|
+
return "automatic reply";
|
|
39
|
+
if (["bulk", "list", "junk"].includes(headers.precedence ?? ""))
|
|
40
|
+
return "bulk mail";
|
|
41
|
+
return undefined;
|
|
42
|
+
}
|
|
43
|
+
/** What the agent reads: the new text of the email (no quoted history), led by the subject on a first message. */
|
|
44
|
+
export function bodyOf(message) {
|
|
45
|
+
const body = (message.extractedText ?? message.text ?? (message.html ? stripHtml(message.html) : "")).trim();
|
|
46
|
+
const subject = message.subject?.trim();
|
|
47
|
+
if (body.length === 0)
|
|
48
|
+
return "";
|
|
49
|
+
return subject && !/^(re|fwd?):/i.test(subject) ? `Subject: ${subject}\n\n${body}` : body;
|
|
50
|
+
}
|
|
51
|
+
export function toInbound(connectionId, message) {
|
|
52
|
+
const text = bodyOf(message);
|
|
53
|
+
if (text.length === 0)
|
|
54
|
+
return undefined;
|
|
55
|
+
return {
|
|
56
|
+
provider: "agentmail",
|
|
57
|
+
connectionId,
|
|
58
|
+
// The message id (not the event id) so the live stream and the catch-up list dedupe against each other.
|
|
59
|
+
eventId: `agentmail:${message.messageId}`,
|
|
60
|
+
threadId: message.threadId,
|
|
61
|
+
text,
|
|
62
|
+
from: message.from,
|
|
63
|
+
replyTo: message.messageId,
|
|
64
|
+
};
|
|
65
|
+
}
|
|
66
|
+
/** Minutes of missed mail answered after a reconnect or restart. */
|
|
67
|
+
export const CATCHUP_MINUTES = 15;
|
|
68
|
+
/**
|
|
69
|
+
* Holds an AgentMail WebSocket open for one inbox. A dropped connection is retried with backoff and, once back,
|
|
70
|
+
* mail that arrived meanwhile is answered too (deduped by message id). Only `message.received` events count:
|
|
71
|
+
* spam, blocked and unauthenticated (spoofed sender) mail uses other event types and never reaches an agent.
|
|
72
|
+
*/
|
|
73
|
+
export function createAgentMailListener(config, transport, deps) {
|
|
74
|
+
const now = deps.now ?? Date.now;
|
|
75
|
+
const sleep = deps.sleep ?? abortableSleep;
|
|
76
|
+
const tracker = new StateTracker(now);
|
|
77
|
+
const abort = new AbortController();
|
|
78
|
+
let loop;
|
|
79
|
+
let socket;
|
|
80
|
+
async function handle(message) {
|
|
81
|
+
const reason = ignoreReason(message, config.inbox);
|
|
82
|
+
if (reason) {
|
|
83
|
+
deps.log(`agentmail ${config.connectionId}: ignored a message (${reason})`);
|
|
84
|
+
return;
|
|
85
|
+
}
|
|
86
|
+
const inbound = toInbound(config.connectionId, message);
|
|
87
|
+
if (!inbound)
|
|
88
|
+
return;
|
|
89
|
+
tracker.received();
|
|
90
|
+
await deps.onMessage(inbound).catch((error) => deps.log(`agentmail ${config.connectionId}: delivery failed: ${error instanceof Error ? error.message : String(error)}`));
|
|
91
|
+
}
|
|
92
|
+
async function session() {
|
|
93
|
+
const opened = await transport.connect();
|
|
94
|
+
socket = opened;
|
|
95
|
+
const closed = new Promise((resolve) => {
|
|
96
|
+
opened.on("close", () => resolve(undefined));
|
|
97
|
+
opened.on("error", (error) => resolve(error instanceof Error ? error.message : "connection error"));
|
|
98
|
+
});
|
|
99
|
+
opened.on("message", (payload) => {
|
|
100
|
+
if (payload?.type === "event" && payload.eventType === "message.received" && payload.message && payload.message.inboxId === config.inbox)
|
|
101
|
+
void handle(payload.message);
|
|
102
|
+
});
|
|
103
|
+
await opened.waitForOpen();
|
|
104
|
+
opened.sendSubscribe({ type: "subscribe", inboxIds: [config.inbox], eventTypes: ["message.received"] });
|
|
105
|
+
tracker.set("connected");
|
|
106
|
+
const missed = await transport.recent(config.inbox, new Date(now() - CATCHUP_MINUTES * 60_000)).catch(() => []);
|
|
107
|
+
for (const message of missed)
|
|
108
|
+
await handle(message);
|
|
109
|
+
const reason = await Promise.race([closed, new Promise((resolve) => abort.signal.addEventListener("abort", () => resolve(undefined), { once: true }))]);
|
|
110
|
+
if (!abort.signal.aborted)
|
|
111
|
+
throw new Error(reason ?? "connection closed");
|
|
112
|
+
}
|
|
113
|
+
async function run() {
|
|
114
|
+
let attempt = 0;
|
|
115
|
+
while (!abort.signal.aborted) {
|
|
116
|
+
try {
|
|
117
|
+
await session();
|
|
118
|
+
attempt = 0;
|
|
119
|
+
}
|
|
120
|
+
catch (error) {
|
|
121
|
+
if (abort.signal.aborted)
|
|
122
|
+
break;
|
|
123
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
124
|
+
tracker.set(attempt === 0 ? "reconnecting" : "failed", message);
|
|
125
|
+
deps.log(`agentmail ${config.connectionId}: ${message}; retrying`);
|
|
126
|
+
attempt += 1;
|
|
127
|
+
await sleep(reconnectDelay(attempt - 1), abort.signal);
|
|
128
|
+
}
|
|
129
|
+
finally {
|
|
130
|
+
try {
|
|
131
|
+
socket?.close();
|
|
132
|
+
}
|
|
133
|
+
catch {
|
|
134
|
+
// already closed
|
|
135
|
+
}
|
|
136
|
+
socket = undefined;
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
tracker.set("stopped");
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
async start() {
|
|
143
|
+
loop = run();
|
|
144
|
+
await Promise.resolve();
|
|
145
|
+
},
|
|
146
|
+
async stop() {
|
|
147
|
+
abort.abort();
|
|
148
|
+
try {
|
|
149
|
+
socket?.close();
|
|
150
|
+
}
|
|
151
|
+
catch {
|
|
152
|
+
// already closed
|
|
153
|
+
}
|
|
154
|
+
await loop;
|
|
155
|
+
},
|
|
156
|
+
state: () => tracker.get(),
|
|
157
|
+
};
|
|
158
|
+
}
|
|
159
|
+
/** The real transport over the AgentMail SDK. The SDK loads on first use, so a Host without AgentMail never needs it. */
|
|
160
|
+
export function sdkAgentMailTransport(options) {
|
|
161
|
+
let client;
|
|
162
|
+
const load = async () => {
|
|
163
|
+
if (client)
|
|
164
|
+
return client;
|
|
165
|
+
const sdk = await import("agentmail");
|
|
166
|
+
client = new sdk.AgentMailClient({ apiKey: options.apiKey, environment: options.region === "eu" ? sdk.AgentMailEnvironment.EuProd : sdk.AgentMailEnvironment.Prod });
|
|
167
|
+
return client;
|
|
168
|
+
};
|
|
169
|
+
return {
|
|
170
|
+
async connect() {
|
|
171
|
+
const sdkClient = await load();
|
|
172
|
+
const socket = await sdkClient.websockets.connect({ reconnectAttempts: 0 });
|
|
173
|
+
return socket;
|
|
174
|
+
},
|
|
175
|
+
async recent(inbox, since) {
|
|
176
|
+
const sdkClient = await load();
|
|
177
|
+
const page = await sdkClient.inboxes.messages.list(inbox, { after: since, labels: ["received"], limit: 50, ascending: true });
|
|
178
|
+
// The list carries previews only; the agent needs the body.
|
|
179
|
+
const full = await Promise.all((page.messages ?? []).map((item) => sdkClient.inboxes.messages.get(inbox, item.messageId)));
|
|
180
|
+
return full;
|
|
181
|
+
},
|
|
182
|
+
};
|
|
183
|
+
}
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may message an agent over its channel. Entries are an email address, a phone number
|
|
3
|
+
* (E.164), `@domain` for everyone at a domain, a Slack user id (`U02ABC123`), a Telegram `@username`,
|
|
4
|
+
* or `*` for anyone. An empty list lets nobody in.
|
|
5
|
+
*/
|
|
6
|
+
export declare const SENDER_ENTRY: RegExp;
|
|
7
|
+
/** "Jane Doe <Jane@X.com>" -> "jane@x.com"; phone numbers lose spaces, dashes and brackets. */
|
|
8
|
+
export declare function normalizeSender(raw: string): string;
|
|
9
|
+
export declare function normalizeEntry(entry: string): string;
|
|
10
|
+
export declare function senderAllowed(allowFrom: readonly string[], sender: string | undefined): boolean;
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Who may message an agent over its channel. Entries are an email address, a phone number
|
|
3
|
+
* (E.164), `@domain` for everyone at a domain, a Slack user id (`U02ABC123`), a Telegram `@username`,
|
|
4
|
+
* or `*` for anyone. An empty list lets nobody in.
|
|
5
|
+
*/
|
|
6
|
+
export const SENDER_ENTRY = /^(\*|@[^\s@]+\.[^\s@]+|[^\s@]+@[^\s@]+\.[^\s@]+|\+[1-9]\d{6,14}|[UW][A-Z0-9]{7,}|@[A-Za-z][A-Za-z0-9_]{4,31})$/;
|
|
7
|
+
/** "Jane Doe <Jane@X.com>" -> "jane@x.com"; phone numbers lose spaces, dashes and brackets. */
|
|
8
|
+
export function normalizeSender(raw) {
|
|
9
|
+
const angle = /<([^<>]+)>/.exec(raw);
|
|
10
|
+
const value = (angle ? angle[1] : raw).trim();
|
|
11
|
+
if (/^[+\d][\d\s().-]*$/.test(value))
|
|
12
|
+
return value.replace(/[\s().-]/g, "");
|
|
13
|
+
return value.toLowerCase();
|
|
14
|
+
}
|
|
15
|
+
export function normalizeEntry(entry) {
|
|
16
|
+
return entry === "*" ? entry : normalizeSender(entry);
|
|
17
|
+
}
|
|
18
|
+
export function senderAllowed(allowFrom, sender) {
|
|
19
|
+
if (allowFrom.length === 0)
|
|
20
|
+
return false;
|
|
21
|
+
if (allowFrom.includes("*"))
|
|
22
|
+
return true;
|
|
23
|
+
if (sender === undefined)
|
|
24
|
+
return false;
|
|
25
|
+
const who = normalizeSender(sender);
|
|
26
|
+
const domain = who.includes("@") ? who.slice(who.lastIndexOf("@")) : undefined;
|
|
27
|
+
return allowFrom.some((entry) => {
|
|
28
|
+
const allowed = normalizeEntry(entry);
|
|
29
|
+
if (allowed === who)
|
|
30
|
+
return true;
|
|
31
|
+
if (!allowed.startsWith("@"))
|
|
32
|
+
return false;
|
|
33
|
+
// `@domain` matches everyone at it; a dotless `@name` is a Telegram username (senders arrive without the `@`).
|
|
34
|
+
return allowed.includes(".") ? domain === allowed : who === allowed.slice(1);
|
|
35
|
+
});
|
|
36
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import { channelHostFor } from "../../channels/host.js";
|
|
3
|
+
const hashOf = (parts) => createHash("sha256").update(JSON.stringify(parts)).digest("hex");
|
|
4
|
+
/** Inbound lookups and replies over the Host's connections (`connections.yaml`). */
|
|
5
|
+
export function channelInboundConnections(cwd) {
|
|
6
|
+
return {
|
|
7
|
+
find(connectionId) {
|
|
8
|
+
try {
|
|
9
|
+
const host = channelHostFor(cwd);
|
|
10
|
+
const connection = host.registry.resolve(connectionId);
|
|
11
|
+
return { provider: connection.provider, secret: connection.secrets.inboundSecret };
|
|
12
|
+
}
|
|
13
|
+
catch {
|
|
14
|
+
return undefined;
|
|
15
|
+
}
|
|
16
|
+
},
|
|
17
|
+
async reply(connectionId, to, text, operationKey) {
|
|
18
|
+
const host = channelHostFor(cwd);
|
|
19
|
+
const provider = host.registry.providerFor(connectionId);
|
|
20
|
+
const connection = host.registry.resolve(connectionId);
|
|
21
|
+
const message = { text };
|
|
22
|
+
// A provider that can answer inside the conversation does; otherwise the reply is a new message to the sender.
|
|
23
|
+
const submit = provider.reply
|
|
24
|
+
? () => provider.reply(connection, to, message, { operationKey })
|
|
25
|
+
: () => provider.send(connection, host.registry.validateTarget(connectionId, { to }), message, { operationKey });
|
|
26
|
+
await host.sends.send({ connectionId, operationKey, hash: hashOf([connectionId, to, message]) }, submit);
|
|
27
|
+
},
|
|
28
|
+
async contact(connectionId) {
|
|
29
|
+
try {
|
|
30
|
+
const host = channelHostFor(cwd);
|
|
31
|
+
const provider = host.registry.providerFor(connectionId);
|
|
32
|
+
if (!provider.contact)
|
|
33
|
+
return undefined;
|
|
34
|
+
return await provider.contact(host.registry.resolve(connectionId, false));
|
|
35
|
+
}
|
|
36
|
+
catch {
|
|
37
|
+
return undefined;
|
|
38
|
+
}
|
|
39
|
+
},
|
|
40
|
+
resolve(connectionId) {
|
|
41
|
+
try {
|
|
42
|
+
const host = channelHostFor(cwd);
|
|
43
|
+
const connection = host.registry.resolve(connectionId);
|
|
44
|
+
return { id: connection.id, provider: connection.provider, config: connection.config, secrets: connection.secrets, credential: connection.credential ? { secrets: connection.credential.secrets } : undefined };
|
|
45
|
+
}
|
|
46
|
+
catch {
|
|
47
|
+
return undefined;
|
|
48
|
+
}
|
|
49
|
+
},
|
|
50
|
+
};
|
|
51
|
+
}
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
import type { DeliveryInput } from "../ledgers.js";
|
|
2
|
+
import type { AgentService } from "../service.js";
|
|
3
|
+
import type { AgentDelivery } from "../store.js";
|
|
4
|
+
/** Waits before each automatic retry of a reply that definitely did not go out. */
|
|
5
|
+
export declare const RETRY_BACKOFF_MS: number[];
|
|
6
|
+
export type { DeliveryInput };
|
|
7
|
+
type Reply = (connectionId: string, to: string, text: string, operationKey: string) => Promise<void>;
|
|
8
|
+
type Outcome = {
|
|
9
|
+
kind: "failed" | "unknown";
|
|
10
|
+
retryable: boolean;
|
|
11
|
+
code: string;
|
|
12
|
+
message: string;
|
|
13
|
+
};
|
|
14
|
+
/** Sorts a send failure into "never left" (safe to retry when transient) and "may have arrived" (never resent on its own). */
|
|
15
|
+
export declare function classifySendFailure(error: unknown): Outcome;
|
|
16
|
+
/**
|
|
17
|
+
* Gets an agent's reply to the person on the channel they wrote from. The turn only counts as succeeded once the
|
|
18
|
+
* reply is delivered: a definite failure is retried with backoff when it may be transient; an unknown outcome is never
|
|
19
|
+
* resent automatically (it could arrive twice); anything left over is a failed turn a person can resend or mark delivered.
|
|
20
|
+
*/
|
|
21
|
+
export declare class ReplyDelivery {
|
|
22
|
+
private readonly options;
|
|
23
|
+
private readonly timers;
|
|
24
|
+
private sweeping;
|
|
25
|
+
constructor(options: {
|
|
26
|
+
service: Pick<AgentService, "deliveries" | "settleInterruptedDeliveries" | "setReplyDelivered" | "clockMs">;
|
|
27
|
+
reply: Reply;
|
|
28
|
+
log?: (message: string) => void;
|
|
29
|
+
backoffMs?: number[];
|
|
30
|
+
});
|
|
31
|
+
private get backoff();
|
|
32
|
+
/** Record the reply and make the first attempt. Resolves with the delivery's state after that attempt. */
|
|
33
|
+
deliver(input: DeliveryInput): Promise<AgentDelivery>;
|
|
34
|
+
/**
|
|
35
|
+
* One send attempt. The right to send is claimed first, atomically (`pending`/`failed`/`unknown` -> `sending`), and
|
|
36
|
+
* the row is what is trusted, not any list the caller read earlier: an attempt that finds the delivery already
|
|
37
|
+
* claimed, sent or no longer due does nothing and returns it as it is. Overlapping sweeps and timers therefore
|
|
38
|
+
* cannot send the same reply twice.
|
|
39
|
+
*/
|
|
40
|
+
private attempt;
|
|
41
|
+
private later;
|
|
42
|
+
/** A timer-driven sweep has no caller to report to; a failure (a store closing mid-sweep) must not escape as an unhandled rejection. */
|
|
43
|
+
private sweepInBackground;
|
|
44
|
+
/** Retry what is due. Also run on a timer, so retries survive a restart. */
|
|
45
|
+
sweep(): Promise<number>;
|
|
46
|
+
/** On start: sends a restart cut off are now unknown; retries that were waiting are picked up by the sweep. */
|
|
47
|
+
start(intervalMs?: number): void;
|
|
48
|
+
stop(): void;
|
|
49
|
+
/** A person resends a reply that failed or whose outcome was unknown. One attempt; no automatic retries after it. */
|
|
50
|
+
resend(turnId: string): Promise<AgentDelivery>;
|
|
51
|
+
/** A person confirms the reply did arrive (for example after an unknown outcome). */
|
|
52
|
+
markDelivered(turnId: string): AgentDelivery;
|
|
53
|
+
}
|
|
@@ -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
|
+
}
|