fix(plugins): re-export probe symbols + declare plugin deps in dashboard
- Hermes / OpenClaw plugin index.ts now re-export `probeHermesBinary` / `probeOpenClawBinary` and their status types so the dashboard's `runtime-provider-probes.ts` façade can import them via the public package entry instead of deep paths. - Dashboard `package.json` adds `@fusion-plugin-examples/hermes-runtime`, `…/openclaw-runtime`, `…/paperclip-runtime` as workspace deps so pnpm symlinks them into `packages/dashboard/node_modules/`. Without these, the new probe imports failed with "Cannot find module" during `pnpm typecheck`. This clears 6 of the 9 outstanding typecheck errors. The remaining 3 are in the in-flight Hermes plugin rewrite (runtime-adapter still imports from a deleted `./pi-module.js`; the new `index.ts` calls a factory with the wrong arg type) and should be resolved by the same change set that landed the rewrite. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,26 +1,38 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import {
|
||||
ConflictError,
|
||||
agentsMe,
|
||||
createIssue,
|
||||
checkoutIssue,
|
||||
discoverPaperclipCliConfig,
|
||||
getIssue,
|
||||
getIssueComments,
|
||||
invokeHeartbeat,
|
||||
getRunEvents,
|
||||
resolvePaperclipConfig,
|
||||
} from "./pi-module.js";
|
||||
wakeAgent,
|
||||
type RunEvent,
|
||||
} from "./paperclip-client.js";
|
||||
import type {
|
||||
AgentRuntime,
|
||||
AgentRuntimeOptions,
|
||||
AgentSessionResult,
|
||||
PaperclipMode,
|
||||
PaperclipRuntimeConfig,
|
||||
PaperclipSession,
|
||||
RuntimeLogger,
|
||||
} from "./types.js";
|
||||
|
||||
const POLL_INITIAL_INTERVAL_MS = 2_000;
|
||||
const POLL_MAX_INTERVAL_MS = 10_000;
|
||||
const POLL_TIMEOUT_MS = 120_000;
|
||||
const TERMINAL_STATUSES = new Set(["done", "cancelled", "in_review"]);
|
||||
/** Run-level statuses that signal we should stop polling events. */
|
||||
const TERMINAL_RUN_STATUSES = new Set<string>([
|
||||
"succeeded",
|
||||
"failed",
|
||||
"cancelled",
|
||||
"timed_out",
|
||||
]);
|
||||
|
||||
const VALID_MODES: ReadonlySet<PaperclipMode> = new Set([
|
||||
"issue-per-prompt",
|
||||
"rolling-issue",
|
||||
"wakeup-only",
|
||||
]);
|
||||
|
||||
function sleep(ms: number): Promise<void> {
|
||||
return new Promise((resolve) => setTimeout(resolve, ms));
|
||||
@@ -31,7 +43,8 @@ function asString(value: unknown): string | undefined {
|
||||
}
|
||||
|
||||
function deriveIssueTitle(prompt: string): string {
|
||||
const firstLine = prompt.split("\n").find((line) => line.trim() !== "") ?? "Fusion runtime prompt";
|
||||
const firstLine =
|
||||
prompt.split("\n").find((line) => line.trim() !== "") ?? "Fusion runtime prompt";
|
||||
return firstLine.slice(0, 200);
|
||||
}
|
||||
|
||||
@@ -43,35 +56,6 @@ function buildIssueDescription(session: PaperclipSession, prompt: string): strin
|
||||
].join("\n\n");
|
||||
}
|
||||
|
||||
function collectCommentText(comments: Array<Record<string, unknown>>): { text: string; thinking: string } {
|
||||
const textParts: string[] = [];
|
||||
const thinkingParts: string[] = [];
|
||||
|
||||
for (const comment of comments) {
|
||||
const body = asString(comment.body)?.trim();
|
||||
if (!body) {
|
||||
continue;
|
||||
}
|
||||
|
||||
textParts.push(body);
|
||||
|
||||
const kind = asString(comment.kind) ?? asString(comment.type);
|
||||
if (kind === "thinking" || kind === "reasoning") {
|
||||
thinkingParts.push(body);
|
||||
continue;
|
||||
}
|
||||
|
||||
if (body.toLowerCase().startsWith("thinking:")) {
|
||||
thinkingParts.push(body.replace(/^thinking:\s*/i, ""));
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
text: textParts.join("\n\n"),
|
||||
thinking: thinkingParts.join("\n\n"),
|
||||
};
|
||||
}
|
||||
|
||||
function pickIssueId(issue: Record<string, unknown>): string {
|
||||
const issueId = asString(issue.id);
|
||||
if (!issueId) {
|
||||
@@ -80,10 +64,20 @@ function pickIssueId(issue: Record<string, unknown>): string {
|
||||
return issueId;
|
||||
}
|
||||
|
||||
function pickIssueStatus(issue: Record<string, unknown>): string {
|
||||
return asString(issue.status) ?? "unknown";
|
||||
function normalizeMode(mode: string | undefined): PaperclipMode {
|
||||
if (mode && VALID_MODES.has(mode as PaperclipMode)) return mode as PaperclipMode;
|
||||
return "rolling-issue";
|
||||
}
|
||||
|
||||
/**
|
||||
* Adapter that drives Paperclip via its modern wakeup + heartbeat-run streaming API.
|
||||
*
|
||||
* Flow per prompt:
|
||||
* 1. Optional issue creation/reuse (depending on `mode`).
|
||||
* 2. POST /api/agents/{agentId}/wakeup with idempotencyKey + payload.
|
||||
* 3. Stream GET /api/heartbeat-runs/{runId}/events; forward log chunks.
|
||||
* 4. Once terminal, fetch the issue + comments for a final answer.
|
||||
*/
|
||||
export class PaperclipRuntimeAdapter implements AgentRuntime {
|
||||
readonly id = "paperclip";
|
||||
readonly name = "Paperclip Runtime";
|
||||
@@ -92,31 +86,82 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
|
||||
private readonly logger: RuntimeLogger;
|
||||
|
||||
constructor(config?: Partial<PaperclipRuntimeConfig>, logger?: RuntimeLogger) {
|
||||
const resolved = resolvePaperclipConfig(
|
||||
config as Record<string, unknown> | undefined,
|
||||
);
|
||||
// resolvePaperclipConfig returns mode as string; narrow at the boundary.
|
||||
this.config = {
|
||||
...resolvePaperclipConfig(config as Record<string, unknown> | undefined),
|
||||
...resolved,
|
||||
mode: normalizeMode(resolved.mode),
|
||||
...config,
|
||||
};
|
||||
this.logger = logger ?? console;
|
||||
}
|
||||
|
||||
async createSession(options: AgentRuntimeOptions): Promise<AgentSessionResult> {
|
||||
if (!this.config.agentId || !this.config.companyId) {
|
||||
const missing = [!this.config.agentId ? "agentId" : null, !this.config.companyId ? "companyId" : null]
|
||||
.filter(Boolean)
|
||||
.join(", ");
|
||||
let effectiveApiUrl = this.config.apiUrl;
|
||||
let effectiveApiKey = this.config.apiKey;
|
||||
|
||||
// CLI transport: read apiUrl (and possibly apiKey) from local paperclipai config.
|
||||
if (this.config.transport === "cli") {
|
||||
const discovery = await discoverPaperclipCliConfig({
|
||||
configPath: this.config.cliConfigPath,
|
||||
});
|
||||
if (!discovery.ok) {
|
||||
throw new Error(
|
||||
`Paperclip CLI mode failed: ${discovery.reason} (Switch to API mode in settings if paperclipai isn't installed.)`,
|
||||
);
|
||||
}
|
||||
effectiveApiUrl = discovery.apiUrl;
|
||||
// Only override apiKey if the user didn't explicitly set one.
|
||||
if (!effectiveApiKey) {
|
||||
effectiveApiKey = discovery.apiKey;
|
||||
}
|
||||
this.logger.info(
|
||||
`Paperclip CLI mode resolved apiUrl=${effectiveApiUrl} (deploymentMode=${discovery.deploymentMode ?? "unknown"})`,
|
||||
);
|
||||
}
|
||||
|
||||
let agentId = this.config.agentId;
|
||||
let companyId = this.config.companyId;
|
||||
|
||||
// Auto-derive agentId/companyId from /agents/me when missing.
|
||||
if (!agentId || !companyId) {
|
||||
try {
|
||||
const me = await agentsMe(effectiveApiUrl, effectiveApiKey);
|
||||
agentId = agentId ?? me.agentId;
|
||||
companyId = companyId ?? me.companyId;
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
throw new Error(
|
||||
`Paperclip runtime could not derive agentId/companyId from /agents/me. Configure them explicitly or check the API key. Underlying error: ${reason}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
if (!agentId || !companyId) {
|
||||
throw new Error(
|
||||
`Paperclip runtime is missing required config: ${missing}. Configure plugin settings (apiUrl, apiKey, agentId, companyId) or PAPERCLIP_* environment variables.`,
|
||||
"Paperclip runtime is missing required config: agentId or companyId. Configure plugin settings (apiUrl, apiKey, agentId, companyId) or PAPERCLIP_* env vars.",
|
||||
);
|
||||
}
|
||||
|
||||
const session: PaperclipSession = {
|
||||
apiUrl: this.config.apiUrl,
|
||||
apiKey: this.config.apiKey,
|
||||
agentId: this.config.agentId,
|
||||
companyId: this.config.companyId,
|
||||
apiUrl: effectiveApiUrl,
|
||||
apiKey: effectiveApiKey,
|
||||
agentId,
|
||||
companyId,
|
||||
sessionId: randomUUID(),
|
||||
systemPrompt: options.systemPrompt,
|
||||
cwd: options.cwd,
|
||||
mode: normalizeMode(this.config.mode),
|
||||
parentIssueId: this.config.parentIssueId,
|
||||
projectId: this.config.projectId,
|
||||
goalId: this.config.goalId,
|
||||
issueId: undefined,
|
||||
turnIndex: 0,
|
||||
runTimeoutMs: this.config.runTimeoutMs ?? 600_000,
|
||||
pollIntervalMs: this.config.pollIntervalMs ?? 500,
|
||||
pollIntervalMaxMs: this.config.pollIntervalMaxMs ?? 2_000,
|
||||
onText: options.onText,
|
||||
onThinking: options.onThinking,
|
||||
onToolStart: options.onToolStart,
|
||||
@@ -124,10 +169,7 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
|
||||
dispose: () => undefined,
|
||||
};
|
||||
|
||||
return {
|
||||
session,
|
||||
sessionFile: undefined,
|
||||
};
|
||||
return { session, sessionFile: undefined };
|
||||
}
|
||||
|
||||
async promptWithFallback(
|
||||
@@ -135,53 +177,104 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
|
||||
prompt: string,
|
||||
_options?: unknown,
|
||||
): Promise<void> {
|
||||
session.onToolStart?.("paperclip.issue", { sessionId: session.sessionId });
|
||||
|
||||
const createdIssue = await createIssue(session.apiUrl, session.apiKey, session.companyId, {
|
||||
title: deriveIssueTitle(prompt),
|
||||
description: buildIssueDescription(session, prompt),
|
||||
status: "backlog",
|
||||
assigneeAgentId: session.agentId,
|
||||
session.turnIndex += 1;
|
||||
const turn = session.turnIndex;
|
||||
session.onToolStart?.("paperclip.run", {
|
||||
sessionId: session.sessionId,
|
||||
mode: session.mode,
|
||||
turn,
|
||||
});
|
||||
|
||||
const issueId = pickIssueId(createdIssue);
|
||||
// ---- Stage 1: issue create/reuse ------------------------------------
|
||||
let issueId: string | undefined;
|
||||
if (session.mode === "issue-per-prompt") {
|
||||
issueId = await this.createIssueForPrompt(session, prompt);
|
||||
} else if (session.mode === "rolling-issue") {
|
||||
if (!session.issueId) {
|
||||
session.issueId = await this.createIssueForPrompt(session, prompt);
|
||||
}
|
||||
issueId = session.issueId;
|
||||
}
|
||||
// wakeup-only: no issue side-effect.
|
||||
|
||||
// ---- Stage 2: wakeup -------------------------------------------------
|
||||
const idempotencyKey = `${session.sessionId}:${turn}`;
|
||||
let runId: string;
|
||||
try {
|
||||
await checkoutIssue(session.apiUrl, session.apiKey, issueId, session.agentId, session.sessionId);
|
||||
const wakeResponse = await wakeAgent(session.apiUrl, session.apiKey, session.agentId, {
|
||||
source: "on_demand",
|
||||
triggerDetail: "manual",
|
||||
reason: "Fusion runtime prompt",
|
||||
idempotencyKey,
|
||||
payload: {
|
||||
fusionSessionId: session.sessionId,
|
||||
prompt,
|
||||
issueId,
|
||||
},
|
||||
});
|
||||
|
||||
if (wakeResponse.status === "skipped") {
|
||||
session.onToolEnd?.("paperclip.run", true, {
|
||||
issueId,
|
||||
runStatus: "skipped",
|
||||
reason: "Paperclip coalesced this wakeup with a recent one (status=skipped).",
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
runId = wakeResponse.id;
|
||||
if (!runId) {
|
||||
session.onToolEnd?.("paperclip.run", true, {
|
||||
issueId,
|
||||
reason: "Paperclip wakeup response missing run id",
|
||||
});
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof ConflictError) {
|
||||
this.logger.warn(`Paperclip checkout conflict for issue ${issueId}; continuing: ${error.message}`);
|
||||
} else {
|
||||
throw error;
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Paperclip wakeup failed: ${reason}`);
|
||||
session.onToolEnd?.("paperclip.run", true, { issueId, reason });
|
||||
return;
|
||||
}
|
||||
|
||||
// ---- Stage 3: stream run events --------------------------------------
|
||||
const stream = await this.streamRunEvents(session, runId);
|
||||
|
||||
// ---- Stage 4: collect final results ----------------------------------
|
||||
let issueStatus: string | undefined;
|
||||
let finalText = stream.text;
|
||||
if (issueId) {
|
||||
try {
|
||||
const issue = await getIssue(session.apiUrl, session.apiKey, issueId);
|
||||
issueStatus = asString(issue.status) ?? undefined;
|
||||
|
||||
// Comment fallback: if no streaming text was captured, use the latest
|
||||
// non-system comment as the visible answer.
|
||||
if (!finalText) {
|
||||
const comments = await getIssueComments(session.apiUrl, session.apiKey, issueId);
|
||||
const latest = pickLatestVisibleComment(comments);
|
||||
if (latest) finalText = latest;
|
||||
}
|
||||
} catch (error) {
|
||||
// Non-fatal — we still have whatever we streamed.
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Paperclip post-run fetch failed: ${reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
await invokeHeartbeat(session.apiUrl, session.apiKey, session.agentId);
|
||||
if (finalText) session.onText?.(finalText);
|
||||
if (stream.thinking) session.onThinking?.(stream.thinking);
|
||||
|
||||
let issue = createdIssue;
|
||||
let status = pickIssueStatus(issue);
|
||||
let intervalMs = POLL_INITIAL_INTERVAL_MS;
|
||||
const startedAt = Date.now();
|
||||
|
||||
while (!TERMINAL_STATUSES.has(status) && Date.now() - startedAt < POLL_TIMEOUT_MS) {
|
||||
await sleep(intervalMs);
|
||||
issue = await getIssue(session.apiUrl, session.apiKey, issueId);
|
||||
status = pickIssueStatus(issue);
|
||||
intervalMs = Math.min(intervalMs * 2, POLL_MAX_INTERVAL_MS);
|
||||
}
|
||||
|
||||
const comments = await getIssueComments(session.apiUrl, session.apiKey, issueId);
|
||||
const { text, thinking } = collectCommentText(comments);
|
||||
if (text) {
|
||||
session.onText?.(text);
|
||||
}
|
||||
if (thinking) {
|
||||
session.onThinking?.(thinking);
|
||||
}
|
||||
|
||||
session.onToolEnd?.("paperclip.issue", false, {
|
||||
const isError = stream.runStatus === "failed" || stream.runStatus === "timed_out";
|
||||
session.onToolEnd?.("paperclip.run", isError || stream.timedOutLocally, {
|
||||
runId,
|
||||
runStatus: stream.runStatus,
|
||||
issueId,
|
||||
status,
|
||||
issueStatus,
|
||||
timedOutLocally: stream.timedOutLocally,
|
||||
deepLink: issueId
|
||||
? `${session.apiUrl.replace(/\/$/, "")}/issues/${issueId}`
|
||||
: undefined,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -192,4 +285,108 @@ export class PaperclipRuntimeAdapter implements AgentRuntime {
|
||||
async dispose(_session: PaperclipSession): Promise<void> {
|
||||
// no-op: Paperclip manages run/session lifecycle server-side
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------
|
||||
// Internals
|
||||
// ---------------------------------------------------------------------
|
||||
|
||||
private async createIssueForPrompt(
|
||||
session: PaperclipSession,
|
||||
prompt: string,
|
||||
): Promise<string> {
|
||||
const created = await createIssue(session.apiUrl, session.apiKey, session.companyId, {
|
||||
title: deriveIssueTitle(prompt),
|
||||
description: buildIssueDescription(session, prompt),
|
||||
status: "todo",
|
||||
assigneeAgentId: session.agentId,
|
||||
...(session.parentIssueId ? { parentId: session.parentIssueId } : {}),
|
||||
...(session.projectId ? { projectId: session.projectId } : {}),
|
||||
...(session.goalId ? { goalId: session.goalId } : {}),
|
||||
});
|
||||
return pickIssueId(created);
|
||||
}
|
||||
|
||||
private async streamRunEvents(
|
||||
session: PaperclipSession,
|
||||
runId: string,
|
||||
): Promise<{
|
||||
text: string;
|
||||
thinking: string;
|
||||
runStatus: string;
|
||||
timedOutLocally: boolean;
|
||||
}> {
|
||||
const startedAt = Date.now();
|
||||
let afterSeq = 0;
|
||||
let interval = session.pollIntervalMs;
|
||||
let runStatus = "running";
|
||||
let timedOutLocally = false;
|
||||
let textBuf = "";
|
||||
let thinkBuf = "";
|
||||
|
||||
while (true) {
|
||||
let events: RunEvent[] = [];
|
||||
try {
|
||||
events = await getRunEvents(
|
||||
session.apiUrl,
|
||||
session.apiKey,
|
||||
runId,
|
||||
afterSeq,
|
||||
200,
|
||||
);
|
||||
} catch (error) {
|
||||
const reason = error instanceof Error ? error.message : String(error);
|
||||
this.logger.warn(`Paperclip getRunEvents failed: ${reason}`);
|
||||
}
|
||||
|
||||
for (const ev of events) {
|
||||
if (typeof ev.seq === "number" && ev.seq > afterSeq) afterSeq = ev.seq;
|
||||
const type = ev.type ?? "";
|
||||
const payload = ev.payload ?? {};
|
||||
if (type === "heartbeat.run.status") {
|
||||
const next = asString(payload.status);
|
||||
if (next) runStatus = next;
|
||||
} else if (type === "heartbeat.run.log") {
|
||||
const chunk = asString(payload.chunk) ?? "";
|
||||
if (!chunk) continue;
|
||||
if (payload.stream === "stdout") {
|
||||
textBuf += chunk;
|
||||
session.onText?.(chunk);
|
||||
} else if (payload.stream === "stderr") {
|
||||
this.logger.warn(`[paperclip:run:${runId}] ${chunk.trimEnd()}`);
|
||||
} else if (payload.stream === "system") {
|
||||
// system messages may carry reasoning/thinking-style content
|
||||
const message = asString(payload.message) ?? chunk;
|
||||
thinkBuf += (thinkBuf ? "\n" : "") + message;
|
||||
}
|
||||
}
|
||||
// Other event types (adapter.invoke, tool calls) are ignored for v1.
|
||||
}
|
||||
|
||||
if (TERMINAL_RUN_STATUSES.has(runStatus)) break;
|
||||
|
||||
if (Date.now() - startedAt > session.runTimeoutMs) {
|
||||
timedOutLocally = true;
|
||||
this.logger.warn(
|
||||
`Paperclip run ${runId} exceeded local runTimeoutMs=${session.runTimeoutMs}; abandoning poll. Run continues server-side.`,
|
||||
);
|
||||
break;
|
||||
}
|
||||
|
||||
await sleep(interval);
|
||||
interval = Math.min(interval * 2, session.pollIntervalMaxMs);
|
||||
}
|
||||
|
||||
return { text: textBuf, thinking: thinkBuf, runStatus, timedOutLocally };
|
||||
}
|
||||
}
|
||||
|
||||
function pickLatestVisibleComment(
|
||||
comments: Array<Record<string, unknown>>,
|
||||
): string | undefined {
|
||||
for (let i = comments.length - 1; i >= 0; i--) {
|
||||
const c = comments[i];
|
||||
const body = asString(c.body)?.trim();
|
||||
if (body) return body;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user