update
This commit is contained in:
1 parent
806200b3d1
commit
5547c29491
216 files changed
+5043
-3352
No files matched your search
@@ -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 */
|
||||
}
|
||||
}
|
||||
@@ -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,4 +1,3 @@
|
||||
export { SqliteSessionStore } from "./sqlite-session-store";
|
||||
export { HumanApprovalHook } from "./approval-hook";
|
||||
export { CostWriter } from "./cost-writer";
|
||||
export { RuntimeBridge } from "./runtime-bridge";
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user