feat(harness): secure workspace and custom tools
This commit is contained in:
@@ -0,0 +1,142 @@
|
||||
import { readFile } from "node:fs/promises";
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import { type Static, Type } from "typebox";
|
||||
import { Check } from "typebox/value";
|
||||
import { HarnessError } from "../errors/harness-error.js";
|
||||
|
||||
export const mockAdsMetricsInputSchema = Type.Object({
|
||||
accountId: Type.String({ minLength: 1, maxLength: 100 }),
|
||||
days: Type.Integer({ minimum: 1, maximum: 30 }),
|
||||
}, { additionalProperties: false });
|
||||
|
||||
const campaignSchema = Type.Object({
|
||||
campaignId: Type.String(),
|
||||
name: Type.String(),
|
||||
spend: Type.Number({ minimum: 0 }),
|
||||
impressions: Type.Integer({ minimum: 0 }),
|
||||
clicks: Type.Integer({ minimum: 0 }),
|
||||
conversions: Type.Integer({ minimum: 0 }),
|
||||
revenue: Type.Number({ minimum: 0 }),
|
||||
}, { additionalProperties: false });
|
||||
|
||||
const fixtureSchema = Type.Object({
|
||||
accounts: Type.Array(Type.Object({
|
||||
accountId: Type.String(),
|
||||
baseDays: Type.Integer({ minimum: 1 }),
|
||||
campaigns: Type.Array(campaignSchema),
|
||||
}, { additionalProperties: false })),
|
||||
}, { additionalProperties: false });
|
||||
|
||||
export type MockAdsMetricsInput = Static<typeof mockAdsMetricsInputSchema>;
|
||||
type CampaignFixture = Static<typeof campaignSchema>;
|
||||
|
||||
export type CampaignMetrics = CampaignFixture & {
|
||||
ctr: number;
|
||||
cpa: number;
|
||||
roas: number;
|
||||
};
|
||||
|
||||
export type AdsMetrics = {
|
||||
accountId: string;
|
||||
days: number;
|
||||
spend: number;
|
||||
impressions: number;
|
||||
clicks: number;
|
||||
ctr: number;
|
||||
conversions: number;
|
||||
cpa: number;
|
||||
revenue: number;
|
||||
roas: number;
|
||||
campaigns: CampaignMetrics[];
|
||||
};
|
||||
|
||||
export async function executeMockAdsMetrics(
|
||||
fixturePath: string,
|
||||
input: MockAdsMetricsInput,
|
||||
): Promise<AdsMetrics> {
|
||||
if (!Check(mockAdsMetricsInputSchema, input)) {
|
||||
throw new HarnessError("INVALID_TOOL_ARGUMENTS", "Invalid mock_ads_metrics arguments");
|
||||
}
|
||||
|
||||
const parsed: unknown = JSON.parse(await readFile(fixturePath, "utf8"));
|
||||
if (!Check(fixtureSchema, parsed)) {
|
||||
throw new HarnessError("INVALID_ADS_FIXTURE", "Ads fixture has an invalid structure");
|
||||
}
|
||||
const fixture = parsed;
|
||||
const account = fixture.accounts.find((candidate) => candidate.accountId === input.accountId);
|
||||
if (account === undefined) {
|
||||
throw new HarnessError("ADS_ACCOUNT_NOT_FOUND", "Mock advertising account not found", {
|
||||
accountId: input.accountId,
|
||||
});
|
||||
}
|
||||
|
||||
const scale = input.days / account.baseDays;
|
||||
const campaigns = account.campaigns.map((campaign) => scaleCampaign(campaign, scale));
|
||||
const spend = sum(campaigns, "spend");
|
||||
const impressions = sum(campaigns, "impressions");
|
||||
const clicks = sum(campaigns, "clicks");
|
||||
const conversions = sum(campaigns, "conversions");
|
||||
const revenue = sum(campaigns, "revenue");
|
||||
|
||||
return {
|
||||
accountId: input.accountId,
|
||||
days: input.days,
|
||||
spend: round2(spend),
|
||||
impressions: Math.round(impressions),
|
||||
clicks: Math.round(clicks),
|
||||
ctr: ratio(clicks * 100, impressions),
|
||||
conversions: Math.round(conversions),
|
||||
cpa: ratio(spend, conversions),
|
||||
revenue: round2(revenue),
|
||||
roas: ratio(revenue, spend),
|
||||
campaigns,
|
||||
};
|
||||
}
|
||||
|
||||
export function createMockAdsMetricsTool(fixturePath: string) {
|
||||
return defineTool({
|
||||
name: "mock_ads_metrics",
|
||||
label: "Mock Ads Metrics",
|
||||
description: "Read deterministic training metrics for a mock advertising account. This does not access a real ad platform.",
|
||||
promptSnippet: "Read mock advertising account metrics",
|
||||
parameters: mockAdsMetricsInputSchema,
|
||||
async execute(_toolCallId, params) {
|
||||
const details = await executeMockAdsMetrics(fixturePath, params);
|
||||
return {
|
||||
content: [{ type: "text", text: JSON.stringify(details) }],
|
||||
details,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
function scaleCampaign(campaign: CampaignFixture, scale: number): CampaignMetrics {
|
||||
const spend = round2(campaign.spend * scale);
|
||||
const impressions = Math.round(campaign.impressions * scale);
|
||||
const clicks = Math.round(campaign.clicks * scale);
|
||||
const conversions = Math.round(campaign.conversions * scale);
|
||||
const revenue = round2(campaign.revenue * scale);
|
||||
return {
|
||||
...campaign,
|
||||
spend,
|
||||
impressions,
|
||||
clicks,
|
||||
conversions,
|
||||
revenue,
|
||||
ctr: ratio(clicks * 100, impressions),
|
||||
cpa: ratio(spend, conversions),
|
||||
roas: ratio(revenue, spend),
|
||||
};
|
||||
}
|
||||
|
||||
function sum(items: CampaignMetrics[], key: "spend" | "impressions" | "clicks" | "conversions" | "revenue"): number {
|
||||
return items.reduce((total, item) => total + item[key], 0);
|
||||
}
|
||||
|
||||
function ratio(numerator: number, denominator: number): number {
|
||||
return denominator === 0 ? 0 : round2(numerator / denominator);
|
||||
}
|
||||
|
||||
function round2(value: number): number {
|
||||
return Math.round((value + Number.EPSILON) * 100) / 100;
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import { defineTool } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import type { SafeWorkspace } from "../workspace/safe-workspace.js";
|
||||
|
||||
export const saveReportDraftInputSchema = Type.Object({
|
||||
title: Type.Optional(Type.String({ minLength: 1, maxLength: 200 })),
|
||||
body: Type.String({ minLength: 1, maxLength: 200_000 }),
|
||||
}, { additionalProperties: false });
|
||||
|
||||
export function createSaveReportDraftTool(workspace: SafeWorkspace) {
|
||||
return defineTool({
|
||||
name: "save_report_draft",
|
||||
label: "Save Report Draft",
|
||||
description: "Save a report draft to the fixed report.md file in the current training Session. Requires approval.",
|
||||
promptSnippet: "Save a report draft after analysis",
|
||||
parameters: saveReportDraftInputSchema,
|
||||
executionMode: "sequential",
|
||||
async execute(_toolCallId, params) {
|
||||
const details = await workspace.writeReportDraft(params);
|
||||
return {
|
||||
content: [{ type: "text", text: `Saved report draft to ${details.relativePath}` }],
|
||||
details,
|
||||
};
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
export type ToolPolicyDecision = "allow" | "ask" | "deny";
|
||||
|
||||
const allowedTools = new Set(["read", "grep", "find", "ls", "mock_ads_metrics"]);
|
||||
|
||||
export function decideToolPolicy(toolName: string): ToolPolicyDecision {
|
||||
if (allowedTools.has(toolName)) return "allow";
|
||||
if (toolName === "save_report_draft") return "ask";
|
||||
return "deny";
|
||||
}
|
||||
Reference in New Issue
Block a user