Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -112,6 +112,41 @@ GOOGLE_CLIENT_SECRET=""
# schedule.
# AGENT_BRIDGE_SECRET=""

# Optional XMPP agent gateway. Set XMPP_COMPONENT_ENABLED to 1 and provide the
# component identity, component secret, and owning organization together. The
# gateway exposes only operations from apps/agent/src/exports. Task state is
# retained in PostgreSQL for 24 hours after admission.
# XMPP_COMPONENT_ENABLED="1"
# XMPP_COMPONENT_JID="gateway.agents.example.com"
# XMPP_COMPONENT_SECRET=""
# XMPP_ORGANIZATION_ID=""
# XMPP_COMPONENT_SERVICE="xmpp://127.0.0.1:5275"
# XMPP_DEFAULT_AGENT_JID="assistant@agents.example.com"
# XMPP_AGENT_DOMAIN="agents.example.com"
# XMPP_SERVER_DOMAIN="example.com"
# XMPP_GATEWAY_ID="gw-1"
# XMPP_AGENT_VERSION="1.0.0"

# Comma-separated domains that can discover and invoke the endpoint. The
# server and agent domains are used when this value is absent.
# XMPP_ALLOWED_CALLER_DOMAINS="example.com,agents.example.com"

# Comma-separated bare JIDs that can invoke destructive exports. Destructive
# exports remain visible but return forbidden when this value is absent.
# XMPP_ALLOW_DESTRUCTIVE_CALLERS="trusted-agent@agents.example.com"

# Optional transport tuning. The copied gateway supplies safe defaults.
# XMPP_XML_LANG="en"
# XMPP_RECEIPT_TIMEOUT_MS="30000"
# XMPP_RECEIPT_MAX_RESENDS="0"
# XMPP_RECEIPT_SWEEP_MS="10000"
# XMPP_RECONNECT_INITIAL_MS="1000"
# XMPP_RECONNECT_MAX_MS="60000"
# XMPP_PING_INTERVAL_MS="60000"
# XMPP_PING_TIMEOUT_MS="10000"
# XMPP_PING_FAILURE_THRESHOLD="2"
# XMPP_MAX_PENDING_IQ_REQUESTS="256"

# PORT="3001"


Expand Down
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -50,3 +50,5 @@ yarn-error.log*

# Agent scratch files
.scratch/
.serena/
.codegraph/
1 change: 1 addition & 0 deletions .oxlintrc.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
".agents/**",
".claude/**",
"apps/api/src/generated/**",
"packages/agent-xmpp/**",
"packages/db/src/generated/**",
"packages/ui/src/components/**",
"tools/oxlint/anti-slop/**"
Expand Down
101 changes: 101 additions & 0 deletions apps/agent/agent/channels/xmpp.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { defineChannel, GET, POST } from "eve/channels";
import {
ExportToolValidationError,
normalizeExportToolError,
} from "../../src/export-tools/errors";
import { createEveExportSend } from "../../src/export-tools/eve-adapter";
import { executeExportTool } from "../../src/export-tools/executor";
import { exportToolManifest } from "../../src/export-tools/manifest";
import { schemaIssues } from "../../src/export-tools/schema";
import {
type ExportStreamEvent,
exportInvocationRequestSchema,
exportStreamEventSchema,
} from "../../src/export-tools/wire";

function authorized(request: Request): boolean {
const secret = process.env.AGENT_BRIDGE_SECRET;
return (
Boolean(secret) &&
request.headers.get("authorization") === `Bearer ${secret}`
);
}

function denied(): Response {
return Response.json({ error: "Unauthorized" }, { status: 401 });
}

export default defineChannel({
routes: [
GET("/internal/xmpp/export-tools/manifest", async (request) => {
if (!authorized(request)) return denied();
return Response.json({ tools: exportToolManifest() });
}),
POST("/internal/xmpp/export-tools/invoke", async (request, { send }) => {
if (!authorized(request)) return denied();
const parsed = exportInvocationRequestSchema.safeParse(
await request.json(),
);
if (!parsed.success) {
return Response.json(
{ error: "Invalid invocation", issues: parsed.error.issues },
{ status: 400 },
);
}
const invocation = {
requestId: parsed.data.requestId,
operation: parsed.data.operation,
caller: parsed.data.caller,
};
const encoder = new TextEncoder();
const stream = new ReadableStream<Uint8Array>({
start(controller) {
let sessionId: string | undefined;
const write = (value: ExportStreamEvent) => {
controller.enqueue(
encoder.encode(
`${JSON.stringify(exportStreamEventSchema.parse(value))}\n`,
),
);
};
const eveSend = createEveExportSend(send, invocation, request.signal);
void executeExportTool(parsed.data.operation, parsed.data.arguments, {
abortSignal: request.signal,
invocation,
progress: async (update) => write({ type: "progress", update }),
send: async (agentRequest) => {
const result = await eveSend(agentRequest);
sessionId = result.sessionId;
return result;
},
})
.then((value) => write({ type: "result", value, sessionId }))
.catch((cause) => {
const error = normalizeExportToolError(
cause instanceof Error
? cause
: new Error("Export tool threw a non-error value", {
cause,
}),
);
write({
type: "error",
error: {
code: error.code,
message: error.message,
issues:
error instanceof ExportToolValidationError
? [...schemaIssues(error.issues)]
: undefined,
},
});
})
.finally(() => controller.close());
},
});
return new Response(stream, {
headers: { "content-type": "application/x-ndjson; charset=utf-8" },
});
}),
],
});
8 changes: 8 additions & 0 deletions apps/agent/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,21 +15,29 @@
"check-types": "tsc --noEmit",
"test": "CRM_TELEMETRY_DISABLED=1 bun test",
"eval": "CRM_TELEMETRY_DISABLED=1 eve eval",
"e2e:xmpp": "bun test/e2e/xmpp-export.e2e.ts",
"lint": "biome check .",
"clean": "rm -rf .turbo .eve node_modules"
},
"dependencies": {
"@agent-xmpp/core": "workspace:*",
"@agent-xmpp/gateway": "workspace:*",
"@agent-xmpp/protocol": "workspace:*",
"@crm/db": "workspace:*",
"@crm/env": "workspace:*",
"@crm/telemetry": "workspace:*",
"@crm/validation": "workspace:*",
"context.dev": "2.10.0",
"eve": "^0.29.4",
"ai": "7.0.47",
"ulid": "3.0.2",
"zod": "^4.4.3"
},
"devDependencies": {
"@crm/typescript-config": "workspace:*",
"@types/bun": "^1.3.14",
"@types/node": "^24.0.0",
"@xmpp/client": "0.14.0",
"just-bash": "^3.2.0",
"microsandbox": "^0.6.8",
"typescript": "^5.9.2"
Expand Down
12 changes: 9 additions & 3 deletions apps/agent/scripts/start.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import { spawn } from "node:child_process";
import { constants } from "node:os";
import { startXmppGatewayHost } from "../src/xmpp/gateway-host";

const rawPort = process.env.AGENT_PORT ?? process.env.PORT ?? "2000";
const port = Number(rawPort);
Expand All @@ -10,6 +11,10 @@ if (!Number.isInteger(port) || port < 1 || port > 65_535) {
);
}

const gateway =
process.env.XMPP_COMPONENT_ENABLED === "1"
? await startXmppGatewayHost()
: null;
const cli = process.platform === "win32" ? "eve.cmd" : "eve";
const child = spawn(cli, ["start", "--port", String(port)], {
stdio: "inherit",
Expand All @@ -18,9 +23,10 @@ const child = spawn(cli, ["start", "--port", String(port)], {

let settled = false;

const finish = (code: number) => {
const finish = async (code: number) => {
if (settled) return;
settled = true;
await gateway?.close();
process.exitCode = code;
};

Expand All @@ -33,10 +39,10 @@ process.once("SIGTERM", forward);

child.once("exit", (code, signal) => {
const signalNumber = signal ? constants.signals[signal] : null;
finish(code ?? (signalNumber ? 128 + signalNumber : 1));
void finish(code ?? (signalNumber ? 128 + signalNumber : 1));
});

child.once("error", (error) => {
console.error(`[agent] could not start eve: ${error.message}`);
finish(1);
void finish(1);
});
9 changes: 9 additions & 0 deletions apps/agent/src/export-tools/define-export-tool.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ExportToolDefinition } from "./types";

export type DefinedExportTool<I, O> = ExportToolDefinition<I, O>;

export function defineExportTool<I, O>(
definition: ExportToolDefinition<I, O>,
): DefinedExportTool<I, O> {
return Object.freeze(definition);
}
70 changes: 70 additions & 0 deletions apps/agent/src/export-tools/errors.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,70 @@
import type { StandardSchemaIssue } from "./types";

export type ExportToolErrorCode =
| "EXPORT_TOOL_NOT_FOUND"
| "INVALID_ARGUMENTS"
| "EXECUTION_FAILED"
| "AGENT_RUN_FAILED"
| "CANCELLED"
| "OUTPUT_VALIDATION_FAILED";

export class ExportToolError extends Error {
constructor(
message: string,
readonly code: ExportToolErrorCode,
options?: ErrorOptions,
) {
super(message, options);
this.name = new.target.name;
}
}

export class ExportToolNotFoundError extends ExportToolError {
constructor(readonly operation: string) {
super(`Export tool not found: ${operation}`, "EXPORT_TOOL_NOT_FOUND");
}
}

export class ExportToolValidationError extends ExportToolError {
constructor(
message: string,
readonly issues: readonly StandardSchemaIssue[],
code: Extract<
ExportToolErrorCode,
"INVALID_ARGUMENTS" | "OUTPUT_VALIDATION_FAILED"
>,
) {
super(message, code);
}
}

export class ExportToolExecutionError extends ExportToolError {
constructor(cause: unknown) {
super("Export tool execution failed", "EXECUTION_FAILED", {
cause,
});
}
}

export class ExportAgentRunError extends ExportToolError {
constructor(message: string, cause?: unknown) {
super(message, "AGENT_RUN_FAILED", { cause });
}
}

export class ExportCancelledError extends ExportToolError {
constructor() {
super("Export invocation was cancelled", "CANCELLED");
}
}

export function normalizeExportToolError(error: Error): ExportToolError {
if (error instanceof ExportToolError) return error;
if (
error instanceof DOMException &&
(error.name === "AbortError" || error.name === "TimeoutError")
) {
return new ExportCancelledError();
}
return new ExportToolExecutionError(error);
}
Loading
Loading