This commit is contained in:
ZacharyZhang-NY committed 2026-04-17 14:03:56 -04:00
1 parent 806200b3d1
commit 5547c29491
216 files changed
+5043 -3352

No files matched your search

-65
View File
@@ -1,65 +0,0 @@
import type { AppDatabase } from "@main/database";
import type { DesktopEvent } from "@shared/types";
interface BeforeRequestPayload {
agentId: string;
companyId: string;
taskId: string | null;
toolCallId: string;
toolName: string;
args: unknown;
}
interface AfterRequestPayload {
toolCallId: string;
toolName: string;
isError: boolean;
}
export interface HumanApprovalHookDeps {
db: AppDatabase;
emitEvent: (event: DesktopEvent) => void;
logger: {
info: (...args: unknown[]) => void;
warn: (...args: unknown[]) => void;
error: (...args: unknown[]) => void;
};
}
/**
* pi `beforeToolCall` / `afterToolCall` bridge.
*
* **Execution-mode contract.** Both AI CEO and AI Co-Founder modes run the
* pi Agent fully autonomously — the framework layer never gates a tool
* call with a synchronous human-approval promise. Under AI Co-Founder, the
* model itself is instructed (via the system prompt + a dedicated
* `request_approval` tool) to pause when it judges human review necessary.
* Blocking every `bash` / `edit` / `write` at the Electron layer would
* defeat the pi-mono runtime and starve the agent of progress.
*
* `before` therefore always approves. `after` is a no-op audit point; the
* agent-event stream persists the real tool record via SqliteSessionStore.
* `cancelAll` remains for shutdown symmetry but has no queued callers.
*/
export class HumanApprovalHook {
constructor(private readonly _deps: HumanApprovalHookDeps) {
void this._deps;
}
async before(_runId: string, _payload: BeforeRequestPayload): Promise<{ block?: boolean; reason?: string }> {
return {};
}
async after(_runId: string, _payload: AfterRequestPayload): Promise<void> {
/* no-op */
}
/**
* Retained so `RuntimeBridge.release()` can call it during app shutdown
* without knowing whether any approvals were actually queued. The
* autonomous hook never queues anything, so this is always a no-op.
*/
cancelAll(_reason: string): void {
/* no-op */
}
}
+19 -17
View File
@@ -5,7 +5,6 @@ import { resolveModel } from "@main/model-resolver";
export interface CostWriterDeps {
db: AppDatabase;
logger: { warn: (...args: unknown[]) => void };
}
interface ObserveInput {
@@ -26,21 +25,24 @@ export class CostWriter {
constructor(private readonly deps: CostWriterDeps) {}
observeAssistantMessage(input: ObserveInput): void {
let amountUsd: number | null = null;
try {
const definition = getConnectorDefinition(input.connectorId);
if (definition.provider) {
const model = resolveModel(definition.provider, input.modelId);
if (model) {
const cost = calculateCost(model, input.usage);
amountUsd = Number.isFinite(cost.total) ? cost.total : null;
}
}
} catch (error) {
this.deps.logger.warn(
`[cost-writer] cost calculation failed for ${input.connectorId}/${input.modelId}: ${error instanceof Error ? error.message : String(error)}`,
// Fail closed on cost calculation — budget policies read `cost_entries`
// to enforce spending gates; an "unattributed" row would silently let
// an agent run unbounded. If pricing cannot be resolved, refuse to
// persist the row and let the caller see the error. The failure is
// visible (throws) instead of invisible (null amount, unattributed=1).
const definition = getConnectorDefinition(input.connectorId);
if (!definition.provider) {
throw new Error(`[cost-writer] connector ${input.connectorId} has no provider mapping`);
}
const model = resolveModel(definition.provider, input.modelId);
if (!model) {
throw new Error(`[cost-writer] model ${input.modelId} not found for provider ${definition.provider}`);
}
const cost = calculateCost(model, input.usage);
if (!Number.isFinite(cost.total)) {
throw new Error(
`[cost-writer] cost calculation returned non-finite total for ${input.connectorId}/${input.modelId}`,
);
amountUsd = null;
}
this.deps.db.queryAll(
@@ -51,8 +53,8 @@ export class CostWriter {
input.taskId,
input.agentId,
input.connectorId,
amountUsd,
amountUsd === null ? 1 : 0,
cost.total,
0,
new Date().toISOString(),
);
}
-1
View File
@@ -1,4 +1,3 @@
export { SqliteSessionStore } from "./sqlite-session-store";
export { HumanApprovalHook } from "./approval-hook";
export { CostWriter } from "./cost-writer";
export { RuntimeBridge } from "./runtime-bridge";
+34 -45
View File
@@ -3,7 +3,6 @@ import type { AppDatabase } from "@main/database";
import type { DesktopEvent } from "@shared/types";
import { ensureTaskThread } from "@main/thread-integration/thread-helpers";
import { SqliteSessionStore } from "./sqlite-session-store";
import { HumanApprovalHook } from "./approval-hook";
import { CostWriter } from "./cost-writer";
import type { CredentialResolver } from "@main/credentials";
import { getConnectorDefinition } from "@main/connectors";
@@ -132,35 +131,33 @@ interface ActiveRunContext {
* - Forwards `agent-event` from the worker to the renderer (push channel).
* - Persists each `message_end` message via `SqliteSessionStore`.
* - Calls `CostWriter` for assistant messages with usage.
* - Translates `beforeToolCall` RPC into an approval row + waits.
* - Translates `afterToolCall` RPC into best-effort audit hooks.
*
* Tool-call gating runs fully in the model layer (AI CEO and AI Co-Founder
* both autonomous); no `beforeToolCall` / `afterToolCall` IPC hop exists.
*/
export class RuntimeBridge {
readonly approvalHook: HumanApprovalHook;
private readonly costWriter: CostWriter;
private readonly activeRuns = new Map<string, ActiveRunContext>();
private readonly sessionStores = new Map<string, SqliteSessionStore>();
constructor(private readonly deps: RuntimeBridgeDeps) {
this.approvalHook = new HumanApprovalHook({
db: deps.db,
emitEvent: deps.emitEvent,
logger: deps.logger,
});
this.costWriter = new CostWriter({ db: deps.db, logger: deps.logger });
this.costWriter = new CostWriter({ db: deps.db });
}
registerActiveRun(context: ActiveRunContext): void {
private registerActiveRun(context: ActiveRunContext): void {
this.activeRuns.set(context.runId, context);
this.getOrCreateStore(context.taskSessionId);
}
releaseActiveRun(runId: string): void {
/**
* Evicts the per-run `activeRuns` context and the per-session store cache.
* Callers must invoke after a `run-finished` worker message so the maps
* stay bounded by concurrent-run count rather than process lifetime.
*/
releaseRunResources(runId: string): void {
const ctx = this.activeRuns.get(runId);
this.activeRuns.delete(runId);
}
loadPriorMessages(taskSessionId: string): AgentMessage[] {
return this.getOrCreateStore(taskSessionId).loadMessages();
if (ctx) this.sessionStores.delete(ctx.taskSessionId);
}
private getOrCreateStore(taskSessionId: string): SqliteSessionStore {
@@ -206,15 +203,26 @@ export class RuntimeBridge {
| { role?: string; usage?: unknown; content?: unknown; toolCallId?: string }
| undefined;
if (ctx && messageRaw?.role === "assistant" && messageRaw.usage) {
this.costWriter.observeAssistantMessage({
runId: ctx.runId,
companyId: ctx.companyId,
agentId: ctx.agentId,
taskId: ctx.taskId,
connectorId: ctx.connectorId,
modelId: ctx.modelId,
usage: messageRaw.usage as never,
});
try {
this.costWriter.observeAssistantMessage({
runId: ctx.runId,
companyId: ctx.companyId,
agentId: ctx.agentId,
taskId: ctx.taskId,
connectorId: ctx.connectorId,
modelId: ctx.modelId,
usage: messageRaw.usage as never,
});
} catch (error) {
// Cost writer fails closed (RUNTIME-105) — surface the failure
// loudly so operators can see a pricing catalog break, but let
// the run continue so the agent's work is not lost. The missing
// row means budget enforcement under-counts this message; a
// repeated failure in the same run is still visible in logs.
this.deps.logger.error(
`[runtime-bridge] cost recording failed for run ${ctx.runId}: ${error instanceof Error ? error.message : String(error)}`,
);
}
}
this.recordThreadEvent(envelope, messageRaw);
}
@@ -265,30 +273,11 @@ export class RuntimeBridge {
/** Handles RPC requests issued by the worker (`rpc-request` envelope). */
async handleRpc(request: {
rpcId: string;
method: "beforeToolCall" | "afterToolCall" | "persistMessage" | "prepareRun";
method: "persistMessage" | "prepareRun";
runId: string;
payload: unknown;
}): Promise<{ ok: true; result: unknown } | { ok: false; error: string }> {
try {
if (request.method === "beforeToolCall") {
const ctx = this.activeRuns.get(request.runId);
if (!ctx) return { ok: true, result: {} };
const payload = request.payload as {
agentId: string;
companyId: string;
taskId: string | null;
toolCallId: string;
toolName: string;
args: unknown;
};
const verdict = await this.approvalHook.before(request.runId, payload);
return { ok: true, result: verdict };
}
if (request.method === "afterToolCall") {
const payload = request.payload as { toolCallId: string; toolName: string; isError: boolean };
await this.approvalHook.after(request.runId, payload);
return { ok: true, result: undefined };
}
if (request.method === "persistMessage") {
const payload = request.payload as { taskSessionId: string; message: AgentMessage };
this.getOrCreateStore(payload.taskSessionId).appendMessage(payload.message);
@@ -19,12 +19,7 @@ export class SqliteSessionStore {
const result: AgentMessage[] = [];
for (const row of rows) {
if (row.kind === "message") {
try {
result.push(JSON.parse(row.payloadJson) as AgentMessage);
} catch {
// Skip malformed entries (defensive) — preserves the rest of the
// transcript instead of failing the entire load.
}
result.push(JSON.parse(row.payloadJson) as AgentMessage);
}
}
return result;