mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-14 08:53:19 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - The chat-feature eval catalog checks that a chat agent does the work a person asks for > - In the latest full-cohort run on master, 16 cases failed although the saved work was correct > - Their graders matched words, one literal value, or one API route in free-form model output > - A grader that fails correct work hides real failures in the noise, and it can also pass wrong work that uses the expected word > - This pull request grades those cases by saved state, audit records, setup literals and rendered page structure, and keeps only narrow checks for false claims > - The benefit is that these cases fail only when the product or agent does the wrong thing ## Linked Issues or Issue Description Refs #18971 (related: company-secret question handoffs) **What happened** The full-cohort chat-eval run on master failed 16 cases where the retained evidence shows correct behaviour. Examples: `companies.rename-company` accepted only `company.updated`, but the supported branding route logs `company.branding_updated`. `recovery.post-stop-message` required a signup button label that matched a regex, and rejected a labelled email form with the button "Start writing free". `secrets.secret-proposal` flagged "the value is delivered when Morgan next runs" as a false delivery claim. Other graders required words such as "uncommitted", "telemetry", "administrator" or the app name that the question did not ask for. **Expected behavior** A grader accepts every supported route and wording that achieves the outcome. It fails when saved state is wrong, when a requested fact is wrong, or when the reply makes a claim that saved state contradicts. **Steps to reproduce** 1. Load the retained observation for one of the cases below from the full-cohort evidence. 2. Grade it with the old oracle. The keyword or route check fails although the saved state is correct. 3. Grade it with the new oracle. It passes. A broken variant (wrong saved state or a contradicting claim) still fails. ## What Changed - `activity.audit-change`: attribution is graded by structure. The reply names the recorded editor, names no other company agent (new setup literal `otherActors`), and states only real transitions. Sentence grammar is no longer matched. - `app-permissions.ask-before-action`: each continuation reply must exist and must link no app record other than the reviewed connection. It no longer must name the app. - `budgets-costs.spend-breakdown`: the reply must point at the record with unresolved usage (task link, identifier or run id) and must not give it a zero or free amount. The words "subscription" and "incomplete" are no longer required. - `cases.browse-cases`: an excluded case may be mentioned in prose. It must not be linked or listed as a result row. - `cases.handoff-same-task`: handoff instructions carry findings forward when they point at the saved `findings` document, reproduce it, or restate it. The premise (a primary Morgan run performs the handoff) is unchanged. - `cases.update-case-facts`: a saved browser value may add clauses that only rule out other platforms ("desktop Safari unaffected"). - `companies.agent-observability`: the reply no longer must name Telemetry and the run log. It must not claim that they are exported. - `companies.rename-company`: both supported name routes (`company.updated`, `company.branding_updated`) are accepted. - `connection-setup.choose-provider`: the answer must exist and must not claim a connected account. The unavailable method no longer must be named; the setup path stays graded by clicking the card or link. - `goals.align-existing-work`: a question card from the answering run counts as the phase-two response. Refusal words and "unchanged" words are no longer required; false change claims still fail. - `recovery.post-stop-message` (and `steering.interrupt-with-direction`, which shares the helper): a visible form with a labelled field and a submit control is a signup action, whatever the button says. - `routines.pause-owned`: the "paused"/"running" words are no longer required. The saved checkpoint proves both; a cancellation claim still fails. - `secrets.secret-proposal` (and `secrets.configure-company-secret`, which shares the helper): a sentence that says when delivery will happen is not a delivery claim. - `skills.explicit-restriction`: the reply no longer must say "administrator". Success claims and invented tool limitations still fail. - `workspaces.inspect-git`: the reply no longer must say "uncommitted". Unchanged HEAD and status prove it; commit or push claims still fail. - Every affected `oracleVersion` is bumped in code and in `chat-catalog-descriptors.json`. No scenario YAML changed. ## Verification - `pnpm exec vitest run --config tests/runner-e2e/vitest.config.ts --testTimeout=180000 tests/runner-e2e/chat-catalog-domains tests/runner-e2e/chat-catalog-contract.test.ts tests/runner-e2e/chat-catalog-consistency.test.ts`: all pass. - `PAPERCLIP_CHAT_EVAL_RENDERED_ARTIFACT_CANARY=1` rendered-artifact canary with Chromium: pass, including new form cases. - `node --test tests/runner-e2e/chat-full-catalog-testops-manifest.test.mjs`: pass. `node tests/runner-e2e/source-sdk-public-inputs.mjs <HEAD> --check`: unchanged pins. - `npx tsc --noEmit -p tests/runner-e2e`: no errors in changed files. - Offline calibration on retained evidence, for each case: the retained correct evidence passes, earlier passing evidence passes where it exists, and a broken variant fails. `spend-breakdown` retained evidence still fails, because its reply shows unresolved usage as `$0.00`. `handoff-same-task` retained evidence still fails on the kept primary-run premise. - Live paid runs: see the comment below. ## Risks - Low risk. The change is limited to eval graders and their unit tests. Product code does not change. - Each grader is more accepting of wording. Saved-state checks and the narrow false-claim checks remain, so a wrong outcome still fails. - `secrets.configure-company-secret` still fails its retained evidence. Its agent asked a two-question card, and the act supports only one-question cards. This pull request changes it only by the shared delivery-claim helper. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), tool use and code execution in Claude Code. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
89 lines
10 KiB
TypeScript
89 lines
10 KiB
TypeScript
import { chatCatalogNativePhaseDeadline, chatCatalogNativePhaseIsTimely } from "../chat-catalog-contract.js";
|
|
import type { RoutineDetail, RoutineRunSummary } from "../../../packages/shared/src/types/routine.js";
|
|
import type { ChatCatalogContext } from "../chat-catalog-contract.js";
|
|
import { pollUntil } from "../api.js";
|
|
import { checks, observation, project, type Entity, type InternalObservation } from "./internal-support.js";
|
|
import { fixtureWorker } from "./internal-definition.js";
|
|
import { startRunBriefGate } from "./internal-run-brief.js";
|
|
import { integratorDefinition, observeInternal, inspectResult } from "./integrator-support.js";
|
|
import type { RuntimeAgent } from "./runtime-support.js";
|
|
const gates = new WeakMap<ChatCatalogContext, Awaited<ReturnType<typeof startRunBriefGate>>>();
|
|
interface PauseObservation extends InternalObservation { routine: RoutineDetail; invocations: RoutineRunSummary[] }
|
|
async function observePause(ctx: ChatCatalogContext, state: Record<string, unknown>): Promise<PauseObservation> {
|
|
const base = await observeInternal(ctx, state); const id = (state.routine as RoutineDetail).id;
|
|
return { ...base, routine: await ctx.input.api.get<RoutineDetail>(`/api/routines/${id}`), invocations: await ctx.input.api.get<RoutineRunSummary[]>(`/api/routines/${id}/runs`) };
|
|
}
|
|
function pauseObservation(value: unknown): PauseObservation | undefined {
|
|
const o = value as PauseObservation; return observation(value) && !!o.routine && Array.isArray(o.invocations) ? o : undefined;
|
|
}
|
|
export function gradeIntegratorRoutinePause(value: unknown) {
|
|
const c = checks(); const o = pauseObservation(value);
|
|
c.add("actual-history-and-active-invocation", !!o, "Requires saved routine, genuine prior execution and the actual in-flight task checkpoint.");
|
|
if (!o) return c.result();
|
|
const before = o.state.routine as RoutineDetail; const first = pauseObservation(o.state.pauseCheckpoint);
|
|
const prior = o.state.priorInvocation as RoutineRunSummary; const active = o.state.activeInvocation as RoutineRunSummary;
|
|
c.add("future-schedule-paused-without-definition-loss", o.routine.id === before?.id && o.routine.status === "paused" && first?.routine.status === "paused"
|
|
&& o.routine.assigneeAgentId === o.leadId && o.routine.description === before.description && o.routine.projectId === before.projectId
|
|
&& o.routine.triggers.length === before.triggers.length && before.triggers.some(trigger => trigger.kind === "schedule" && trigger.enabled)
|
|
&& o.routine.triggers.every(trigger => before.triggers.some(old => old.id === trigger.id && old.cronExpression === trigger.cronExpression && old.timezone === trigger.timezone)),
|
|
"The supported persisted pause excludes future scheduled dispatch while retaining the same owned routine and its saved trigger definition.");
|
|
const checkpointRun = first?.runs.find(run => run.id === o.state.activeNativeRunId);
|
|
c.add("pause-does-not-cancel-inflight", !!first && checkpointRun?.status === "running" && checkpointRun.agentId === (o.state.worker as Entity)?.id
|
|
&& first.tasks.some(task => task.id === active?.linkedIssueId && task.status === "in_progress")
|
|
// The pause and the still-running task are proven by saved state at the response; the reply must
|
|
// exist and must not claim the in-flight work was cancelled. Its wording is left to a human reader.
|
|
&& !!first.reply.trim()
|
|
&& !/\b(?:cancelled|canceled|terminated|stopped)\b[^.!?\n]{0,35}\b(?:running|in.flight|task|execution)\b|\b(?:task|execution|work)\b[^.!?\n]{0,25}\b(?:cancelled|canceled|terminated)\b/i.test(first.reply),
|
|
"At the actual response the separately assigned execution is still genuinely running and the reply does not claim it was cancelled.");
|
|
const savedPrior = o.invocations.find(run => run.id === prior?.id); const savedActive = o.invocations.find(run => run.id === active?.id);
|
|
c.add("history-retained-and-inflight-completes-normally", o.invocations.length === 2 && savedPrior?.status === "completed" && savedPrior.linkedIssueId === prior.linkedIssueId
|
|
&& savedActive?.status === "completed" && savedActive.linkedIssueId === active.linkedIssueId
|
|
&& o.runs.some(run => run.id === o.state.activeNativeRunId && run.status === "succeeded") && o.tasks.some(task => task.id === active.linkedIssueId && task.status === "done"),
|
|
"Both real invocation histories remain accessible and the preexisting in-flight task completes normally after its actual dependency is released.");
|
|
return c.result();
|
|
}
|
|
export const integratorRoutinePauseCases = [integratorDefinition("routines", "routines.pause-owned", async ctx => {
|
|
const p = await project(ctx, "Launch"); const worker = await fixtureWorker(ctx);
|
|
for (const scope of [{ scopeType: "company", scopeId: ctx.input.fixtures.company.id }, { scopeType: "agent", scopeId: worker.id }])
|
|
await ctx.input.api.post(`/api/companies/${ctx.input.fixtures.company.id}/budgets/policies`, { ...scope, amount: 500, hardStopEnabled: true, isActive: true, unpricedUsagePolicy: "allow" });
|
|
const routine = await ctx.input.api.post<RoutineDetail>(`/api/companies/${ctx.input.fixtures.company.id}/routines`, { title: "Daily launch summaries", projectId: p.id, assigneeAgentId: ctx.input.fixtures.agent.id,
|
|
description: "Save one short factual Launch summary in a task comment, then complete this summary task. Do not create other work.", concurrencyPolicy: "always_enqueue" });
|
|
// Public manual execution supports a one-invocation assignee override. The
|
|
// routine remains owned by the CEO while this ordinary task runs as Morgan.
|
|
const priorInvocation = await ctx.input.api.post<RoutineRunSummary>(`/api/routines/${routine.id}/run`, { source: "manual", assigneeAgentId: worker.id, idempotencyKey: `prior-pause-${ctx.input.nonce}` });
|
|
await pollUntil({ label: "real completed routine history before pause", deadlineAt: Date.now() + 180_000, intervalMs: 1000,
|
|
load: () => ctx.input.api.get<RoutineRunSummary[]>(`/api/routines/${routine.id}/runs`), accept: rows => rows.length === 1 && rows[0]?.status === "completed" });
|
|
const gate = await startRunBriefGate(ctx, worker.id); gates.set(ctx, gate);
|
|
try {
|
|
await ctx.input.api.patch(`/api/routines/${routine.id}`, { description: "This Launch summary is waiting for its current website brief. First call the connected Website Brief app's read_website_brief tool and wait for its real response; do not substitute earlier context. Then save a short summary of the received brief in a task comment and mark this summary task done. Do not create tasks or change routine settings." });
|
|
const activeInvocation = await ctx.input.api.post<RoutineRunSummary>(`/api/routines/${routine.id}/run`, { source: "manual", assigneeAgentId: worker.id, idempotencyKey: `active-pause-${ctx.input.nonce}` });
|
|
const phase = await pollUntil({ label: "actual routine task waiting for source brief", deadlineAt: Date.now() + 150_000, intervalMs: 1000,
|
|
load: async () => ({ runs: await ctx.allRuns(), task: await ctx.input.api.get<Entity>(`/api/issues/${activeInvocation.linkedIssueId}`) }),
|
|
accept: value => value.runs.length === 2 && value.task.status === "in_progress" && gate.calls.some(call => call.authorized) && value.runs.some(run => run.contextSnapshot?.issueId === activeInvocation.linkedIssueId && run.status === "running") });
|
|
await ctx.input.api.post(`/api/routines/${routine.id}/triggers`, { kind: "schedule", cronExpression: "0 9 * * *", timezone: "America/Chicago", enabled: true });
|
|
return { project: p, worker: { id: worker.id }, routine: await ctx.input.api.get<RoutineDetail>(`/api/routines/${routine.id}`), priorInvocation, activeInvocation,
|
|
activeNativeRunId: phase.runs.find(run => run.contextSnapshot?.issueId === activeInvocation.linkedIssueId)?.id, gateObserved: gate.calls.some(call => call.authorized) };
|
|
} catch (error) { await gate.close(); gates.delete(ctx); throw error; }
|
|
}, observePause, gradeIntegratorRoutinePause, async (ctx, state) => {
|
|
const routine = state.routine as RoutineDetail; await inspectResult(ctx, `/${ctx.input.fixtures.company.issuePrefix}/routines/${routine.id}/history`, routine.title);
|
|
}, { sourceFiles: ["chat-catalog-domains/integrator-routine-pause.ts", "chat-catalog-domains/internal-run-brief.ts", "chat-catalog-domains/internal-definition.ts", "chat-catalog-domains/internal-support.ts", "chat-catalog-domains/integrator-support.ts", "chat-scenarios/routines.yaml"],
|
|
oracleVersion: "paperclip.chat-catalog.routine-pause.structural.v2", expectedRunCount: 3, attemptTimeoutMs: 720_000,
|
|
setupPolicy: { readiness: "preserve-scenario", invariantCheckIds: ["lead-ready", "owned-routine-real-active-task"] },
|
|
async verifySetup(ctx, state) {
|
|
const c = checks(); const lead = await ctx.input.api.get<RuntimeAgent>(`/api/agents/${ctx.input.fixtures.agent.id}`);
|
|
const active = (await ctx.allRuns()).find(run => run.id === state.activeNativeRunId);
|
|
c.add("lead-ready", lead.lifecycleState === "ready" && ["idle", "active"].includes(lead.status ?? ""), "The real routine owner is ready for the canonical chat.");
|
|
c.add("owned-routine-real-active-task", active?.status === "running" && active.agentId === (state.worker as Entity).id && active.contextSnapshot?.issueId === (state.activeInvocation as RoutineRunSummary).linkedIssueId
|
|
&& state.gateObserved === true && (state.routine as RoutineDetail).assigneeAgentId === lead.id, "The saved CEO-owned routine has a genuinely busy separately assigned ordinary invocation.");
|
|
return c.result();
|
|
},
|
|
async act(ctx, state) {
|
|
const checkpoint = await pollUntil({ label: "actual pause reply while invocation remains running", deadlineAt: chatCatalogNativePhaseDeadline(ctx, Date.now() + 150_000), intervalMs: 1000,
|
|
load: () => observePause(ctx, state), accept: o => chatCatalogNativePhaseIsTimely(ctx) && (o.runs.length === 3 && o.runs.some(run => run.agentId === o.leadId && run.status === "succeeded") && ctx.issue()?.conversationState === "waiting") });
|
|
state.pauseCheckpoint = { ...checkpoint, state: structuredClone(state) };
|
|
await ctx.input.evidence("chat-routine-pause-running-checkpoint.json", state.pauseCheckpoint);
|
|
gates.get(ctx)?.release();
|
|
},
|
|
async cleanup(ctx) { const gate = gates.get(ctx); if (gate) { await gate.close(); gates.delete(ctx); } },
|
|
})];
|