@desplega.ai/agent-swarm 1.84.0 → 1.85.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 +48 -8
- package/openapi.json +5 -3
- package/package.json +1 -1
- package/src/be/db-queries/oauth.ts +33 -0
- package/src/be/db.ts +7 -1
- package/src/be/migrations/076_kapso_sender_user_backfill.sql +43 -0
- package/src/be/migrations/077_oauth_refresh_locks.sql +8 -0
- package/src/commands/context-preamble.ts +178 -0
- package/src/commands/runner.ts +87 -7
- package/src/http/index.ts +11 -3
- package/src/http/tasks.ts +17 -0
- package/src/http/users.ts +11 -3
- package/src/http/utils.ts +17 -0
- package/src/integrations/kapso/inbound.ts +36 -0
- package/src/oauth/ensure-token.ts +97 -11
- package/src/prompts/base-prompt.ts +15 -2
- package/src/prompts/session-templates.ts +26 -12
- package/src/providers/pi-mono-adapter.ts +44 -25
- package/src/server.ts +2 -0
- package/src/tasks/worker-follow-up.ts +82 -0
- package/src/tests/agentmail-sending-skill.test.ts +75 -0
- package/src/tests/agents-list-model-display.test.ts +45 -0
- package/src/tests/base-prompt.test.ts +90 -1
- package/src/tests/db-queries-oauth.test.ts +27 -0
- package/src/tests/ensure-token.test.ts +71 -0
- package/src/tests/http-log-scrubbing.test.ts +24 -0
- package/src/tests/http-users.test.ts +53 -0
- package/src/tests/kapso-inbound.test.ts +60 -1
- package/src/tests/kv-page-proxy.test.ts +1 -0
- package/src/tests/list-endpoint-slimming.test.ts +22 -1
- package/src/tests/oauth-access-token-tool.test.ts +138 -0
- package/src/tests/pagination-metrics.test.ts +4 -4
- package/src/tests/pi-mono-adapter.test.ts +37 -1
- package/src/tests/prompt-template-session.test.ts +13 -3
- package/src/tests/runner-context-preamble.test.ts +202 -0
- package/src/tests/runner-fallback-output.test.ts +118 -39
- package/src/tests/task-completion-idempotency.test.ts +89 -0
- package/src/tools/cancel-task.ts +13 -5
- package/src/tools/get-task-details.ts +18 -10
- package/src/tools/get-tasks.ts +9 -4
- package/src/tools/oauth-access-token.ts +118 -0
- package/src/tools/send-task.ts +9 -5
- package/src/tools/store-progress.ts +12 -77
- package/src/tools/task-action.ts +20 -10
- package/src/tools/tool-config.ts +2 -1
- package/src/types.ts +5 -0
- package/src/utils/secret-scrubber.ts +23 -0
- package/templates/skills/agentmail-sending/SKILL.md +148 -28
package/src/http/tasks.ts
CHANGED
|
@@ -22,6 +22,7 @@ import {
|
|
|
22
22
|
updateTaskVcs,
|
|
23
23
|
} from "../be/db";
|
|
24
24
|
import { createTaskWithSiblingAwareness } from "../tasks/sibling-awareness";
|
|
25
|
+
import { createWorkerTaskFollowUp } from "../tasks/worker-follow-up";
|
|
25
26
|
import { telemetry } from "../telemetry";
|
|
26
27
|
import {
|
|
27
28
|
type AgentTaskSource,
|
|
@@ -635,6 +636,22 @@ export async function handleTasks(
|
|
|
635
636
|
filter: ({}, ctx) => ctx.deps.length > 0,
|
|
636
637
|
conditions: [{ timeout_ms: 3_600_000 }], // 1 hour: task running time
|
|
637
638
|
});
|
|
639
|
+
|
|
640
|
+
try {
|
|
641
|
+
const followUp = createWorkerTaskFollowUp({
|
|
642
|
+
task: result.task,
|
|
643
|
+
status: parsed.body.status,
|
|
644
|
+
output: parsed.body.output,
|
|
645
|
+
failureReason: parsed.body.failureReason,
|
|
646
|
+
});
|
|
647
|
+
if (followUp) {
|
|
648
|
+
console.log(
|
|
649
|
+
`[tasks.finish] Created follow-up task ${followUp.id.slice(0, 8)} for ${parsed.body.status} task ${parsed.params.id.slice(0, 8)}`,
|
|
650
|
+
);
|
|
651
|
+
}
|
|
652
|
+
} catch (err) {
|
|
653
|
+
console.warn(`[tasks.finish] Failed to create follow-up task: ${err}`);
|
|
654
|
+
}
|
|
638
655
|
}
|
|
639
656
|
|
|
640
657
|
json(res, {
|
package/src/http/users.ts
CHANGED
|
@@ -151,7 +151,11 @@ const resolveUnmapped = route({
|
|
|
151
151
|
params: z.object({ kind: z.string(), externalId: z.string() }),
|
|
152
152
|
body: z.union([
|
|
153
153
|
z.object({ userId: z.string().min(1) }),
|
|
154
|
-
z.object({
|
|
154
|
+
z.object({
|
|
155
|
+
name: z.string().min(1),
|
|
156
|
+
email: z.string().email().optional(),
|
|
157
|
+
notes: z.string().optional(),
|
|
158
|
+
}),
|
|
155
159
|
]),
|
|
156
160
|
responses: {
|
|
157
161
|
200: { description: "Identity linked + kv entries cleared" },
|
|
@@ -322,7 +326,7 @@ const deleteIdentityRoute = route({
|
|
|
322
326
|
|
|
323
327
|
// ─── Helpers ─────────────────────────────────────────────────────────────────
|
|
324
328
|
|
|
325
|
-
const UNMAPPED_KINDS = ["slack", "github", "gitlab", "linear"] as const;
|
|
329
|
+
const UNMAPPED_KINDS = ["slack", "github", "gitlab", "linear", "kapso"] as const;
|
|
326
330
|
|
|
327
331
|
/**
|
|
328
332
|
* Group the two-key-per-identity kv entries (`<externalId>:meta` json +
|
|
@@ -477,7 +481,11 @@ export async function handleUsers(
|
|
|
477
481
|
}
|
|
478
482
|
targetUserId = existing.id;
|
|
479
483
|
} else {
|
|
480
|
-
const created = createUser({
|
|
484
|
+
const created = createUser({
|
|
485
|
+
name: parsed.body.name,
|
|
486
|
+
email: parsed.body.email,
|
|
487
|
+
notes: parsed.body.notes,
|
|
488
|
+
});
|
|
481
489
|
targetUserId = created.id;
|
|
482
490
|
}
|
|
483
491
|
linkIdentity(targetUserId, kind, externalId, actor);
|
package/src/http/utils.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import type { IncomingMessage, ServerResponse } from "node:http";
|
|
2
2
|
import { getActiveTaskCount } from "../be/db";
|
|
3
|
+
import { scrubSecrets } from "../utils/secret-scrubber";
|
|
3
4
|
|
|
4
5
|
export function setCorsHeaders(req: IncomingMessage, res: ServerResponse) {
|
|
5
6
|
// Echo the request Origin (rather than emitting `*`) so credentialed fetches
|
|
@@ -46,6 +47,22 @@ export function getPathSegments(url: string): string[] {
|
|
|
46
47
|
return path.split("/").filter(Boolean);
|
|
47
48
|
}
|
|
48
49
|
|
|
50
|
+
export function safeRequestUrlForLog(rawUrl: string | undefined): string {
|
|
51
|
+
if (!rawUrl) return "";
|
|
52
|
+
|
|
53
|
+
try {
|
|
54
|
+
const url = new URL(rawUrl, "http://localhost");
|
|
55
|
+
const params = Array.from(url.searchParams.keys());
|
|
56
|
+
if (params.length === 0) return url.pathname;
|
|
57
|
+
|
|
58
|
+
const redactedQuery = params.map((key) => `${key}=[REDACTED]`).join("&");
|
|
59
|
+
return `${url.pathname}?${redactedQuery}`;
|
|
60
|
+
} catch {
|
|
61
|
+
const pathOnly = rawUrl.split("?")[0] || rawUrl;
|
|
62
|
+
return scrubSecrets(pathOnly);
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
|
|
49
66
|
/** Add capacity info to agent response */
|
|
50
67
|
export function agentWithCapacity<T extends { id: string; maxTasks?: number }>(
|
|
51
68
|
agent: T,
|
|
@@ -2,8 +2,13 @@ import { resolveTemplate } from "@/prompts/resolver";
|
|
|
2
2
|
import { createTaskWithSiblingAwareness } from "@/tasks/sibling-awareness";
|
|
3
3
|
import { workflowEventBus } from "@/workflows/event-bus";
|
|
4
4
|
import "@/tools/templates";
|
|
5
|
+
import { recordUnmappedIdentity } from "@/be/unmapped-identities";
|
|
6
|
+
import { findUserByExternalId } from "@/be/users";
|
|
5
7
|
import { getKapsoNumberMapping, markKapsoMessageSeen } from "./config";
|
|
6
8
|
|
|
9
|
+
const KAPSO_IDENTITY_KIND = "kapso";
|
|
10
|
+
const WHATSAPP_IDENTITY_KIND = "whatsapp";
|
|
11
|
+
|
|
7
12
|
/** Minimal shape of the Kapso v2 inbound webhook payload (see the kapso-whatsapp skill). */
|
|
8
13
|
export interface KapsoWebhookPayload {
|
|
9
14
|
message?: {
|
|
@@ -50,6 +55,36 @@ function buildTaskDescription(payload: KapsoWebhookPayload): string {
|
|
|
50
55
|
}).text;
|
|
51
56
|
}
|
|
52
57
|
|
|
58
|
+
function normalizeKapsoSender(payload: KapsoWebhookPayload): string | null {
|
|
59
|
+
const raw = payload.message?.from ?? payload.conversation?.phone_number ?? "";
|
|
60
|
+
const digits = raw.replace(/\D/g, "");
|
|
61
|
+
return digits || null;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
function resolveKapsoRequestedByUserId(payload: KapsoWebhookPayload): string | undefined {
|
|
65
|
+
const externalId = normalizeKapsoSender(payload);
|
|
66
|
+
if (!externalId) return undefined;
|
|
67
|
+
|
|
68
|
+
const mapped =
|
|
69
|
+
findUserByExternalId(KAPSO_IDENTITY_KIND, externalId) ??
|
|
70
|
+
findUserByExternalId(WHATSAPP_IDENTITY_KIND, externalId);
|
|
71
|
+
if (mapped) return mapped.id;
|
|
72
|
+
|
|
73
|
+
recordUnmappedIdentity(KAPSO_IDENTITY_KIND, externalId, {
|
|
74
|
+
sampleEventType: "kapso.message.received",
|
|
75
|
+
sampleContext: [
|
|
76
|
+
payload.conversation?.contact_name ? `contact=${payload.conversation.contact_name}` : null,
|
|
77
|
+
payload.conversation?.id ? `conversation=${payload.conversation.id}` : null,
|
|
78
|
+
payload.message?.id ? `message=${payload.message.id}` : null,
|
|
79
|
+
payload.phone_number_id ? `phone_number_id=${payload.phone_number_id}` : null,
|
|
80
|
+
]
|
|
81
|
+
.filter(Boolean)
|
|
82
|
+
.join(" "),
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
return undefined;
|
|
86
|
+
}
|
|
87
|
+
|
|
53
88
|
/**
|
|
54
89
|
* Route one inbound Kapso webhook delivery. Pure of HTTP concerns — the caller
|
|
55
90
|
* handles HMAC verification and the workflow-trigger dispatch (which needs the
|
|
@@ -104,6 +139,7 @@ export function routeKapsoInbound(payload: KapsoWebhookPayload): KapsoRouting {
|
|
|
104
139
|
taskType: "kapso-inbound",
|
|
105
140
|
tags: ["kapso-whatsapp", "inbound"],
|
|
106
141
|
priority: 70,
|
|
142
|
+
requestedByUserId: resolveKapsoRequestedByUserId(payload),
|
|
107
143
|
contextKey: `kapso:conversation:${payload.conversation?.id ?? messageId}`,
|
|
108
144
|
});
|
|
109
145
|
|
|
@@ -1,6 +1,18 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import {
|
|
2
|
+
acquireOAuthRefreshLock,
|
|
3
|
+
getOAuthApp,
|
|
4
|
+
getOAuthTokens,
|
|
5
|
+
isTokenExpiringSoon,
|
|
6
|
+
releaseOAuthRefreshLock,
|
|
7
|
+
} from "../be/db-queries/oauth";
|
|
8
|
+
import type { OAuthTokens } from "../tracker/types";
|
|
2
9
|
import { type OAuthProviderConfig, refreshAccessToken } from "./wrapper";
|
|
3
10
|
|
|
11
|
+
const refreshLocks = new Map<string, Promise<void>>();
|
|
12
|
+
const REFRESH_LOCK_TTL_MS = 2 * 60 * 1000;
|
|
13
|
+
const REFRESH_LOCK_WAIT_MS = 30 * 1000;
|
|
14
|
+
const REFRESH_LOCK_POLL_MS = 250;
|
|
15
|
+
|
|
4
16
|
/**
|
|
5
17
|
* Build an OAuthProviderConfig from the oauth_apps table for any provider.
|
|
6
18
|
*/
|
|
@@ -22,6 +34,41 @@ function getOAuthConfig(provider: string): OAuthProviderConfig | null {
|
|
|
22
34
|
};
|
|
23
35
|
}
|
|
24
36
|
|
|
37
|
+
async function withProviderRefreshLock<T>(provider: string, fn: () => Promise<T>): Promise<T> {
|
|
38
|
+
const previous = refreshLocks.get(provider) ?? Promise.resolve();
|
|
39
|
+
let release!: () => void;
|
|
40
|
+
const current = new Promise<void>((resolve) => {
|
|
41
|
+
release = resolve;
|
|
42
|
+
});
|
|
43
|
+
const next = previous.catch(() => undefined).then(() => current);
|
|
44
|
+
refreshLocks.set(provider, next);
|
|
45
|
+
|
|
46
|
+
await previous.catch(() => undefined);
|
|
47
|
+
|
|
48
|
+
try {
|
|
49
|
+
return await fn();
|
|
50
|
+
} finally {
|
|
51
|
+
release();
|
|
52
|
+
if (refreshLocks.get(provider) === next) {
|
|
53
|
+
refreshLocks.delete(provider);
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
function sleep(ms: number): Promise<void> {
|
|
59
|
+
return new Promise((resolve) => setTimeout(resolve, ms));
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
function tokenRowChanged(current: OAuthTokens | null, observed: OAuthTokens | null): boolean {
|
|
63
|
+
if (!observed) return current !== null;
|
|
64
|
+
if (!current) return true;
|
|
65
|
+
return (
|
|
66
|
+
current.accessToken !== observed.accessToken ||
|
|
67
|
+
current.refreshToken !== observed.refreshToken ||
|
|
68
|
+
current.expiresAt !== observed.expiresAt
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
25
72
|
/**
|
|
26
73
|
* Ensure a valid OAuth token exists for the given provider.
|
|
27
74
|
* If the token is expiring soon, attempt to refresh it.
|
|
@@ -57,16 +104,55 @@ export async function ensureToken(provider: string, bufferMs?: number): Promise<
|
|
|
57
104
|
*/
|
|
58
105
|
export async function ensureTokenOrThrow(provider: string, bufferMs?: number): Promise<void> {
|
|
59
106
|
if (!isTokenExpiringSoon(provider, bufferMs)) return;
|
|
107
|
+
const observedTokens = getOAuthTokens(provider);
|
|
60
108
|
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
109
|
+
await withProviderRefreshLock(provider, async () => {
|
|
110
|
+
const waitStartedAt = Date.now();
|
|
111
|
+
|
|
112
|
+
while (isTokenExpiringSoon(provider, bufferMs)) {
|
|
113
|
+
const tokens = getOAuthTokens(provider);
|
|
114
|
+
if (tokenRowChanged(tokens, observedTokens)) return;
|
|
115
|
+
|
|
116
|
+
const config = getOAuthConfig(provider);
|
|
117
|
+
if (!config || !tokens?.refreshToken) {
|
|
118
|
+
console.warn(
|
|
119
|
+
`[OAuth] ${provider} token expiring but cannot refresh (missing config or refresh token)`,
|
|
120
|
+
);
|
|
121
|
+
return;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
const lockOwner = acquireOAuthRefreshLock(provider, REFRESH_LOCK_TTL_MS);
|
|
125
|
+
if (!lockOwner) {
|
|
126
|
+
if (Date.now() - waitStartedAt > REFRESH_LOCK_WAIT_MS) {
|
|
127
|
+
throw new Error(`Timed out waiting for ${provider} OAuth token refresh lock`);
|
|
128
|
+
}
|
|
129
|
+
await sleep(REFRESH_LOCK_POLL_MS);
|
|
130
|
+
continue;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
try {
|
|
134
|
+
const lockedTokens = getOAuthTokens(provider);
|
|
135
|
+
if (
|
|
136
|
+
!isTokenExpiringSoon(provider, bufferMs) ||
|
|
137
|
+
tokenRowChanged(lockedTokens, observedTokens)
|
|
138
|
+
) {
|
|
139
|
+
return;
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
const lockedConfig = getOAuthConfig(provider);
|
|
143
|
+
if (!lockedConfig || !lockedTokens?.refreshToken) {
|
|
144
|
+
console.warn(
|
|
145
|
+
`[OAuth] ${provider} token expiring but cannot refresh (missing config or refresh token)`,
|
|
146
|
+
);
|
|
147
|
+
return;
|
|
148
|
+
}
|
|
69
149
|
|
|
70
|
-
|
|
71
|
-
|
|
150
|
+
await refreshAccessToken(lockedConfig, lockedTokens.refreshToken);
|
|
151
|
+
console.log(`[OAuth] ${provider} token refreshed successfully`);
|
|
152
|
+
return;
|
|
153
|
+
} finally {
|
|
154
|
+
releaseOAuthRefreshLock(provider, lockOwner);
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
});
|
|
72
158
|
}
|
|
@@ -23,6 +23,13 @@ const BOOTSTRAP_TOTAL_MAX_CHARS = 150_000;
|
|
|
23
23
|
const truncationNotice = (file: string) =>
|
|
24
24
|
`\n\n[...truncated, see /workspace/${file} for full content]\n`;
|
|
25
25
|
|
|
26
|
+
export function areSlackPromptToolsEnabled(env: NodeJS.ProcessEnv = process.env): boolean {
|
|
27
|
+
const slackDisable = env.SLACK_DISABLE;
|
|
28
|
+
if (slackDisable === "true" || slackDisable === "1") return false;
|
|
29
|
+
|
|
30
|
+
return Boolean(env.SLACK_BOT_TOKEN && env.SLACK_APP_TOKEN);
|
|
31
|
+
}
|
|
32
|
+
|
|
26
33
|
export type BasePromptArgs = {
|
|
27
34
|
role: string;
|
|
28
35
|
agentId: string;
|
|
@@ -71,9 +78,15 @@ export const getBasePrompt = async (args: BasePromptArgs): Promise<string> => {
|
|
|
71
78
|
const compositeResult = await resolveTemplateAsync(compositeEventType, vars);
|
|
72
79
|
let prompt = compositeResult.text;
|
|
73
80
|
|
|
81
|
+
const slackPromptToolsEnabled = areSlackPromptToolsEnabled();
|
|
82
|
+
|
|
83
|
+
if (hasMcp && slackPromptToolsEnabled) {
|
|
84
|
+
const slackResult = await resolveTemplateAsync("system.agent.slack", {});
|
|
85
|
+
prompt += slackResult.text;
|
|
86
|
+
}
|
|
87
|
+
|
|
74
88
|
// Conditionally inject Slack instructions for workers with Slack-originated tasks
|
|
75
|
-
|
|
76
|
-
if (role !== "lead" && args.slackContext && hasMcp) {
|
|
89
|
+
if (role !== "lead" && args.slackContext && hasMcp && slackPromptToolsEnabled) {
|
|
77
90
|
const slackResult = await resolveTemplateAsync("system.agent.worker.slack", {
|
|
78
91
|
slackChannelId: args.slackContext.channelId,
|
|
79
92
|
slackThreadTs: args.slackContext.threadTs ?? "",
|
|
@@ -59,16 +59,11 @@ As the lead agent, you coordinate all worker agents in the swarm.
|
|
|
59
59
|
- \`send-task\`: Assign a task to a specific worker or to the general pool. Slack/AgentMail metadata auto-inherits from parent task.
|
|
60
60
|
- \`store-progress\`: Track coordination notes or update task status
|
|
61
61
|
|
|
62
|
-
**User Registration:** When a task arrives from an unknown user (no \`requestedByUserId\`), use the \`manage-user\` tool to register them before proceeding. Resolve their identity from the
|
|
63
|
-
|
|
64
|
-
**Slack:**
|
|
65
|
-
- \`slack-reply\`: Reply to user in the Slack thread (use taskId for context)
|
|
66
|
-
- \`slack-read\`: Read thread/channel history (use taskId or channelId)
|
|
67
|
-
- \`slack-list-channels\`: Discover available Slack channels
|
|
62
|
+
**User Registration:** When a task arrives from an unknown user (no \`requestedByUserId\`), use the \`manage-user\` tool to register them before proceeding. Resolve their identity from the task metadata attached to the task.
|
|
68
63
|
|
|
69
64
|
**Identity:**
|
|
70
65
|
- \`update-profile\`: Update your own or other agents' profile fields (name, role, capabilities, soulMd, identityMd, heartbeatMd, claudeMd, toolsMd, setupScript)
|
|
71
|
-
- \`manage-user\`: Register or update human users (resolve from
|
|
66
|
+
- \`manage-user\`: Register or update human users (resolve from GitHub/GitLab identity or other source metadata)
|
|
72
67
|
|
|
73
68
|
#### Task Routing
|
|
74
69
|
|
|
@@ -85,16 +80,14 @@ When composing task descriptions: include the repo URL (if applicable), specific
|
|
|
85
80
|
For follow-up tasks that should continue from previous work, pass \`parentTaskId\` with the previous task's ID:
|
|
86
81
|
- Worker resumes the parent's Claude session (full conversation context preserved)
|
|
87
82
|
- Child task is auto-routed to the same worker (session data is local)
|
|
88
|
-
- Slack metadata (channelId, threadTs, userId) auto-inherits
|
|
89
83
|
|
|
90
84
|
If you explicitly assign to a different worker, session resume gracefully falls back to a fresh session.
|
|
91
85
|
|
|
92
|
-
#### Follow-Up Tasks
|
|
86
|
+
#### Follow-Up Tasks
|
|
93
87
|
|
|
94
88
|
When a worker completes or fails a task, you receive an automatic follow-up task. Handle it by:
|
|
95
89
|
1. Review the output/failure reason
|
|
96
|
-
2.
|
|
97
|
-
3. Complete this task. Do NOT re-delegate or create new worker tasks from a follow-up \u2014 the worker's result IS the answer. Only escalate to the stakeholder if the worker explicitly failed and the failure needs human attention.
|
|
90
|
+
2. Complete this task. Do NOT re-delegate or create new worker tasks from a follow-up \u2014 the worker's result IS the answer. Only escalate to the stakeholder if the worker explicitly failed and the failure needs human attention.
|
|
98
91
|
|
|
99
92
|
#### Heartbeat Checklist
|
|
100
93
|
|
|
@@ -106,7 +99,6 @@ The system reads your \`/workspace/HEARTBEAT.md\` every 30 minutes. If it has co
|
|
|
106
99
|
|
|
107
100
|
**Example standing orders:**
|
|
108
101
|
\`\`\`markdown
|
|
109
|
-
- Check Slack for unaddressed requests older than 1 hour
|
|
110
102
|
- Review active tasks for any that seem stuck or need follow-up
|
|
111
103
|
- If idle workers exist and unassigned tasks are available, investigate why
|
|
112
104
|
\`\`\`
|
|
@@ -122,6 +114,28 @@ The system reads your \`/workspace/HEARTBEAT.md\` every 30 minutes. If it has co
|
|
|
122
114
|
category: "system",
|
|
123
115
|
});
|
|
124
116
|
|
|
117
|
+
registerTemplate({
|
|
118
|
+
eventType: "system.agent.slack",
|
|
119
|
+
header: "",
|
|
120
|
+
defaultBody: `
|
|
121
|
+
#### Slack Tools
|
|
122
|
+
|
|
123
|
+
- \`slack-reply\`: Reply to user in the Slack thread (use taskId for context)
|
|
124
|
+
- \`slack-read\`: Read thread/channel history (use taskId or channelId)
|
|
125
|
+
- \`slack-list-channels\`: Discover available Slack channels
|
|
126
|
+
|
|
127
|
+
**Slack User Registration:** When a task arrives from an unknown user (no \`requestedByUserId\`) with Slack metadata, use the \`manage-user\` tool to register them before proceeding. Resolve their identity from the Slack metadata (user ID, display name) attached to the task.
|
|
128
|
+
|
|
129
|
+
**Slack context inheritance:** For follow-up tasks using \`parentTaskId\`, Slack metadata (channelId, threadTs, userId) auto-inherits.
|
|
130
|
+
|
|
131
|
+
**Slack follow-up tasks:** When a worker completes or fails a task that has Slack metadata, use \`slack-reply\` with the task's ID to post the result back to the originating thread before completing the follow-up task.
|
|
132
|
+
|
|
133
|
+
**Slack standing orders:** If you maintain heartbeat standing orders, check Slack for unaddressed requests older than 1 hour when appropriate.
|
|
134
|
+
`,
|
|
135
|
+
variables: [],
|
|
136
|
+
category: "system",
|
|
137
|
+
});
|
|
138
|
+
|
|
125
139
|
registerTemplate({
|
|
126
140
|
eventType: "system.agent.worker",
|
|
127
141
|
header: "",
|
|
@@ -316,6 +316,26 @@ function cleanupAgentsMdSymlink(cwd: string): void {
|
|
|
316
316
|
}
|
|
317
317
|
}
|
|
318
318
|
|
|
319
|
+
function extractTextContent(content: unknown): string {
|
|
320
|
+
if (typeof content === "string") return content.trim();
|
|
321
|
+
if (!Array.isArray(content)) return "";
|
|
322
|
+
return content
|
|
323
|
+
.filter(
|
|
324
|
+
(c): c is { type?: string; text?: string } =>
|
|
325
|
+
typeof c === "object" && c !== null && (c as { type?: string }).type === "text",
|
|
326
|
+
)
|
|
327
|
+
.map((c) => c.text || "")
|
|
328
|
+
.join("")
|
|
329
|
+
.trim();
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
export function extractPiAssistantText(message: unknown): string {
|
|
333
|
+
if (!message || typeof message !== "object") return "";
|
|
334
|
+
const msg = message as { role?: string; content?: unknown };
|
|
335
|
+
if (msg.role !== "assistant") return "";
|
|
336
|
+
return extractTextContent(msg.content);
|
|
337
|
+
}
|
|
338
|
+
|
|
319
339
|
export class PiMonoSession implements ProviderSession {
|
|
320
340
|
private listeners: Array<(event: ProviderEvent) => void> = [];
|
|
321
341
|
private eventQueue: ProviderEvent[] = [];
|
|
@@ -327,6 +347,8 @@ export class PiMonoSession implements ProviderSession {
|
|
|
327
347
|
private logFileHandle: ReturnType<ReturnType<typeof Bun.file>["writer"]>;
|
|
328
348
|
/** Track last emitted message text to avoid duplicates across turns */
|
|
329
349
|
private lastEmittedMessage = "";
|
|
350
|
+
/** Last assistant text surfaced by pi-mono; used as runner fallback output. */
|
|
351
|
+
private lastAssistantText = "";
|
|
330
352
|
/** Phase 7: wallclock start so we can populate `durationMs` on the cost row. */
|
|
331
353
|
private sessionStartedAt: number = Date.now();
|
|
332
354
|
/**
|
|
@@ -391,31 +413,27 @@ export class PiMonoSession implements ProviderSession {
|
|
|
391
413
|
private handleAgentEvent(event: AgentSessionEvent): void {
|
|
392
414
|
switch (event.type) {
|
|
393
415
|
case "message_end": {
|
|
394
|
-
//
|
|
395
|
-
|
|
396
|
-
|
|
397
|
-
|
|
398
|
-
|
|
399
|
-
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
|
|
403
|
-
:
|
|
404
|
-
|
|
405
|
-
|
|
406
|
-
|
|
407
|
-
|
|
408
|
-
|
|
409
|
-
|
|
410
|
-
|
|
411
|
-
|
|
412
|
-
|
|
413
|
-
|
|
414
|
-
|
|
415
|
-
}),
|
|
416
|
-
});
|
|
417
|
-
this.lastEmittedMessage = text;
|
|
418
|
-
}
|
|
416
|
+
// Pi emits message_end for user, assistant, and tool-result messages.
|
|
417
|
+
// Only assistant text should be printed or used as fallback output.
|
|
418
|
+
const text = extractPiAssistantText(event.message);
|
|
419
|
+
if (text) {
|
|
420
|
+
this.lastAssistantText = text;
|
|
421
|
+
}
|
|
422
|
+
if (text && text !== this.lastEmittedMessage) {
|
|
423
|
+
const model = this.reportedModel();
|
|
424
|
+
this.emit({
|
|
425
|
+
type: "raw_log",
|
|
426
|
+
content: JSON.stringify({
|
|
427
|
+
type: "assistant",
|
|
428
|
+
message: {
|
|
429
|
+
role: "assistant",
|
|
430
|
+
content: [{ type: "text", text }],
|
|
431
|
+
model,
|
|
432
|
+
},
|
|
433
|
+
}),
|
|
434
|
+
});
|
|
435
|
+
this.emit({ type: "message", role: "assistant", content: text });
|
|
436
|
+
this.lastEmittedMessage = text;
|
|
419
437
|
}
|
|
420
438
|
// Emit context_usage for dashboard tracking.
|
|
421
439
|
// Phase 7: derive `outputTokens` from `SessionStats` delta (pi-ai's
|
|
@@ -522,6 +540,7 @@ export class PiMonoSession implements ProviderSession {
|
|
|
522
540
|
exitCode: 0,
|
|
523
541
|
sessionId: this._sessionId,
|
|
524
542
|
cost,
|
|
543
|
+
output: this.lastAssistantText || undefined,
|
|
525
544
|
isError: false,
|
|
526
545
|
};
|
|
527
546
|
} catch (err) {
|
package/src/server.ts
CHANGED
|
@@ -43,6 +43,7 @@ import { registerMemoryGetTool } from "./tools/memory-get";
|
|
|
43
43
|
import { registerMemoryRateTool } from "./tools/memory-rate";
|
|
44
44
|
import { registerMemorySearchTool } from "./tools/memory-search";
|
|
45
45
|
import { registerMyAgentInfoTool } from "./tools/my-agent-info";
|
|
46
|
+
import { registerGetOauthAccessTokenTool } from "./tools/oauth-access-token";
|
|
46
47
|
import { registerPollTaskTool } from "./tools/poll-task";
|
|
47
48
|
import { registerPostMessageTool } from "./tools/post-message";
|
|
48
49
|
// Prompt template tools
|
|
@@ -199,6 +200,7 @@ export function createServer() {
|
|
|
199
200
|
|
|
200
201
|
// Debug tools - always registered (self-guards with lead check)
|
|
201
202
|
registerDbQueryTool(server);
|
|
203
|
+
registerGetOauthAccessTokenTool(server);
|
|
202
204
|
|
|
203
205
|
// Swarm config tools - always registered (config management is fundamental)
|
|
204
206
|
registerSetConfigTool(server);
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
import { createTaskExtended, getAgentById, getLeadAgent, getTaskAttachments } from "../be/db";
|
|
2
|
+
import { resolveTemplate } from "../prompts/resolver";
|
|
3
|
+
import type { AgentTask, TaskAttachment } from "../types";
|
|
4
|
+
// Side-effect import: registers task lifecycle templates in the in-memory registry.
|
|
5
|
+
import "../tools/templates";
|
|
6
|
+
|
|
7
|
+
function attachmentPointer(a: TaskAttachment): string {
|
|
8
|
+
switch (a.kind) {
|
|
9
|
+
case "url":
|
|
10
|
+
return a.url ?? "";
|
|
11
|
+
case "page":
|
|
12
|
+
return `page:${a.pageId ?? ""}`;
|
|
13
|
+
case "agent-fs":
|
|
14
|
+
return `agent-fs:${a.path ?? ""}`;
|
|
15
|
+
case "shared-fs":
|
|
16
|
+
return `shared-fs:${a.path ?? ""}`;
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
function formatAttachmentsBlock(attachments: TaskAttachment[]): string {
|
|
21
|
+
if (attachments.length === 0) return "";
|
|
22
|
+
const lines = attachments.map((a) => {
|
|
23
|
+
const tag = a.isPrimary ? "[primary] " : "";
|
|
24
|
+
const intent = a.intent ? ` (intent: ${a.intent})` : "";
|
|
25
|
+
return `- ${tag}${a.name} - ${attachmentPointer(a)}${intent}`;
|
|
26
|
+
});
|
|
27
|
+
return `\n\nAttachments (${attachments.length}):\n${lines.join("\n")}`;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function createWorkerTaskFollowUp(args: {
|
|
31
|
+
task: AgentTask;
|
|
32
|
+
status: "completed" | "failed";
|
|
33
|
+
output?: string;
|
|
34
|
+
failureReason?: string;
|
|
35
|
+
}): AgentTask | null {
|
|
36
|
+
const { task, status, output, failureReason } = args;
|
|
37
|
+
|
|
38
|
+
if (task.workflowRunId) return null;
|
|
39
|
+
|
|
40
|
+
const taskAgent = getAgentById(task.agentId ?? "");
|
|
41
|
+
if (!taskAgent || taskAgent.isLead) return null;
|
|
42
|
+
|
|
43
|
+
const leadAgent = getLeadAgent();
|
|
44
|
+
if (!leadAgent) return null;
|
|
45
|
+
|
|
46
|
+
const agentName = taskAgent.name || task.agentId?.slice(0, 8) || "Unknown";
|
|
47
|
+
const taskDesc = task.task.slice(0, 200);
|
|
48
|
+
|
|
49
|
+
let followUpDescription: string;
|
|
50
|
+
if (status === "completed") {
|
|
51
|
+
const attachmentsBlock = formatAttachmentsBlock(getTaskAttachments(task.id));
|
|
52
|
+
const outputSummary = output
|
|
53
|
+
? `${output.slice(0, 500)}${output.length > 500 ? "..." : ""}${attachmentsBlock}`
|
|
54
|
+
: `(no output)${attachmentsBlock}`;
|
|
55
|
+
const completedResult = resolveTemplate("task.worker.completed", {
|
|
56
|
+
agent_name: agentName,
|
|
57
|
+
task_desc: taskDesc,
|
|
58
|
+
output_summary: outputSummary,
|
|
59
|
+
task_id: task.id,
|
|
60
|
+
});
|
|
61
|
+
followUpDescription = completedResult.text;
|
|
62
|
+
} else {
|
|
63
|
+
const reason = failureReason || "(no reason given)";
|
|
64
|
+
const failedResult = resolveTemplate("task.worker.failed", {
|
|
65
|
+
agent_name: agentName,
|
|
66
|
+
task_desc: taskDesc,
|
|
67
|
+
failure_reason: reason,
|
|
68
|
+
task_id: task.id,
|
|
69
|
+
});
|
|
70
|
+
followUpDescription = failedResult.text;
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
return createTaskExtended(followUpDescription, {
|
|
74
|
+
agentId: leadAgent.id,
|
|
75
|
+
source: "system",
|
|
76
|
+
taskType: "follow-up",
|
|
77
|
+
parentTaskId: task.id,
|
|
78
|
+
slackChannelId: task.slackChannelId,
|
|
79
|
+
slackThreadTs: task.slackThreadTs,
|
|
80
|
+
slackUserId: task.slackUserId,
|
|
81
|
+
});
|
|
82
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { describe, expect, test } from "bun:test";
|
|
2
|
+
|
|
3
|
+
const skillPath = `${import.meta.dir}/../../templates/skills/agentmail-sending/SKILL.md`;
|
|
4
|
+
const skill = await Bun.file(skillPath).text();
|
|
5
|
+
const curlInboxVariable = "$" + "{INBOX}";
|
|
6
|
+
const scriptApiKeyVariable = "$" + "{apiKey}";
|
|
7
|
+
|
|
8
|
+
function requireMatch(pattern: RegExp, label: string): RegExpMatchArray {
|
|
9
|
+
const match = skill.match(pattern);
|
|
10
|
+
if (!match) {
|
|
11
|
+
throw new Error(`Missing ${label}`);
|
|
12
|
+
}
|
|
13
|
+
return match;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
describe("agentmail-sending skill template", () => {
|
|
17
|
+
test("pins the canonical base URL and rejects the hallucinated host", () => {
|
|
18
|
+
expect(skill).toContain("```text\nhttps://api.agentmail.to/v0/\n```");
|
|
19
|
+
expect(skill).toContain("DO NOT use `api.agentmail.ai`");
|
|
20
|
+
expect(skill).not.toContain("https://api.agentmail.ai");
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
test("pins send-message field names and text-only guidance", () => {
|
|
24
|
+
expect(skill).toContain("```text\nto\nbcc\nsubject\ntext\n```");
|
|
25
|
+
expect(skill).toContain("Use `text`, NOT `text_body`, `body`, or `content`.");
|
|
26
|
+
expect(skill).toContain("Do NOT pass `html`.");
|
|
27
|
+
});
|
|
28
|
+
|
|
29
|
+
test("curl example uses the canonical endpoint, bearer auth, and exact JSON fields", () => {
|
|
30
|
+
expect(skill).toContain("https://api.agentmail.to/v0/inboxes/{inbox}/messages/send");
|
|
31
|
+
expect(skill).toContain(
|
|
32
|
+
[
|
|
33
|
+
'curl -sS -X POST "https://api.agentmail.to/v0/inboxes/',
|
|
34
|
+
curlInboxVariable,
|
|
35
|
+
'/messages/send"',
|
|
36
|
+
].join(""),
|
|
37
|
+
);
|
|
38
|
+
expect(skill).toContain('-H "Authorization: Bearer $AGENTMAIL_API_KEY"');
|
|
39
|
+
|
|
40
|
+
const jsonBlock = requireMatch(
|
|
41
|
+
/--data-binary @- <<'JSON'\n([\s\S]*?)\nJSON/,
|
|
42
|
+
"curl JSON body",
|
|
43
|
+
)[1];
|
|
44
|
+
const payload = JSON.parse(jsonBlock);
|
|
45
|
+
|
|
46
|
+
expect(Object.keys(payload)).toEqual(["to", "bcc", "subject", "text"]);
|
|
47
|
+
expect(payload).not.toHaveProperty("text_body");
|
|
48
|
+
expect(payload).not.toHaveProperty("body");
|
|
49
|
+
expect(payload).not.toHaveProperty("content");
|
|
50
|
+
expect(payload).not.toHaveProperty("html");
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
test("script_upsert example uses fetch and resolves AGENTMAIL_API_KEY from swarm config", () => {
|
|
54
|
+
const scriptBlock = requireMatch(/```ts\n([\s\S]*?)\n```/, "script_upsert example")[1];
|
|
55
|
+
|
|
56
|
+
expect(scriptBlock).toContain("await script_upsert({");
|
|
57
|
+
expect(scriptBlock).toContain("ctx.swarm.config.get('AGENTMAIL_API_KEY')");
|
|
58
|
+
expect(scriptBlock).toContain("ctx.stdlib.fetch(");
|
|
59
|
+
expect(scriptBlock).toContain("https://api.agentmail.to/v0/inboxes/");
|
|
60
|
+
expect(scriptBlock).toContain("messages/send");
|
|
61
|
+
expect(scriptBlock).toContain(
|
|
62
|
+
["Authorization: \\`Bearer \\", scriptApiKeyVariable, "\\`"].join(""),
|
|
63
|
+
);
|
|
64
|
+
expect(scriptBlock).toContain("text: args.text");
|
|
65
|
+
expect(scriptBlock).not.toContain("text_body");
|
|
66
|
+
expect(scriptBlock).not.toContain("html:");
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
test("common error table covers known AgentMail mistakes", () => {
|
|
70
|
+
expect(skill).toContain("404 on `/v0/inboxes/.../send`");
|
|
71
|
+
expect(skill).toContain('422 `{"detail":"text Field required"}`');
|
|
72
|
+
expect(skill).toContain("401");
|
|
73
|
+
expect(skill).toContain("HTML rendering bug");
|
|
74
|
+
});
|
|
75
|
+
});
|