mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 22:13:59 +01:00
## Thinking Path > - Paperclip manages work for companies of AI agents. > - App writes can require a human review for each invocation. > - Decline and Always allow are separate controls. > - The gateway currently reuses a declined action across later human requests with the same arguments. > - A new request can therefore receive the old decline instead of a new review. > - This change uses the saved run and human comment to distinguish a new intent. > - The new intent still follows the current policy and approval gates. ## Linked Issues or Issue Description **What happened?** After a person declines an Ask first app write, a later request from that person for the same write receives `action_declined`. The gateway matches company, task, agent, tool and arguments across runs. It does not distinguish the later human request. **Expected behavior** A distinct later request from the same responsible person can open a new Ask first review. It must not execute the write or reuse approval. A retry of the declined intent, including its rejection continuation, must remain declined. **Steps to reproduce** 1. Ask an agent to perform an app write that requires approval. 2. Decline the proposed action. 3. Send a new request for the same action. 4. Observe that the gateway reuses the old rejection and creates no new pending review. **Paperclip version or commit** The behavior is present on master `7b8aa60b6d` and in a retained native journey. The focused parent control reproduces the matching error. No related open fix was found in the scoped PR search. ## What Changed - Only a rejected match can be released for a later human intent. - Require the active run, company, task, agent and responsible person to match the saved direct-human comment. Reject derived, deleted, untrusted or system-authored input and invalid chronology. - Preserve all other replay states, signed argument checks, current policy, unresolved-effect guards, approval and execution owners. - Document the distinction between decline and a standing policy change. ## Verification - Actual matching-function control: parent fails; this change passes. The control uses the unchanged owner body with synthetic database rows. It does not claim real PostgreSQL or provider verification. - 52 focused pure controls pass. These cover same-intent retries, rejection continuations, foreign scope and actor, invalid authorship, chronology and other action states. - TypeScript 7.0.2 checks pass for the new helper and its owning tests. - `node scripts/check-maps.mjs` and `git diff --check` pass. - The existing gateway service suite has a new signed-action integration test. It covers ordinary and reopened human requests. It requires the old rejection plus one new pending request, a stable retry, and no completed call. Each leaf prints one fixed success label after its assertions so CI can distinguish execution from a platform skip. No local PostgreSQL, app, model or provider execution was run. Current-head normal CI41594912212 completed with48 successful jobs and1 expected skip. Both real PostgreSQL leaves executed and emitted their post-assertion markers; the owning job passed. Full typecheck/build checks passed in CI. ## Risks This adds one indexed run/comment read only after a rejected matching action. Missing identity or ambiguous provenance keeps the old rejection. Legacy and external comments without direct-human provenance do not reset it. A new human intent is not consent to execute. This core PR remains unmerged. Candidate native journey qualification is separate; the original failed journey is retained. ## Model Used OpenAI `gpt-6.1-sol` with reasoning, source inspection and tool use. The host did not expose the context-window size. ## 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 #123` / `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 - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
61 lines
4.1 KiB
TypeScript
61 lines
4.1 KiB
TypeScript
/** A fresh human request may ask for another review, never inherit approval.
|
|
* These values come only from persisted run/comment rows, not tool arguments. */
|
|
interface ReplaySession {
|
|
companyId: string; agentId: string | null; issueId: string | null; runId: string | null;
|
|
responsibleUserId?: string | null;
|
|
}
|
|
interface ReplayRun {
|
|
id: string; companyId: string; agentId: string; status: string; responsibleUserId: string | null;
|
|
startedAt: Date | null; issueId?: string | null; nativeIssueId?: string | null;
|
|
contextSnapshot: Record<string, unknown> | null;
|
|
}
|
|
interface HumanComment {
|
|
id: string; companyId: string; issueId: string; authorType: string | null;
|
|
authorUserId: string | null; authorAgentId: string | null; createdByRunId: string | null;
|
|
derivedAuthorAgentId: string | null; derivedCreatedByRunId: string | null; derivedAuthorSource: string | null;
|
|
deletedAt: Date | null; sourceTrust: unknown; presentation: { kind?: string } | null;
|
|
createdAt: Date; body: string;
|
|
}
|
|
interface RejectedAction {
|
|
companyId: string; issueId: string | null; status: string; requestedByAgentId: string | null;
|
|
resolvedByUserId: string | null; decidedByUserId: string | null;
|
|
resolvedByAgentId: string | null; decidedByAgentId: string | null;
|
|
resolvedAt: Date | null; decidedAt: Date | null;
|
|
}
|
|
interface Invocation {
|
|
companyId: string; issueId: string | null; agentId: string | null; runId: string | null;
|
|
}
|
|
|
|
export function isNewHumanIntentAfterToolRejection(input: {
|
|
session: ReplaySession; rejection: RejectedAction; invocation: Invocation;
|
|
run: ReplayRun; comment: HumanComment;
|
|
}): boolean {
|
|
const { session, rejection, invocation, run, comment } = input;
|
|
if (rejection.status !== "rejected" || !session.runId || !session.issueId || !session.agentId || !session.responsibleUserId
|
|
|| rejection.companyId !== session.companyId || rejection.issueId !== session.issueId || rejection.requestedByAgentId !== session.agentId
|
|
|| invocation.companyId !== session.companyId || invocation.issueId !== session.issueId || invocation.agentId !== session.agentId
|
|
|| !invocation.runId || invocation.runId === session.runId
|
|
|| run.id !== session.runId || run.companyId !== session.companyId || run.agentId !== session.agentId
|
|
|| run.status !== "running" || run.responsibleUserId !== session.responsibleUserId) return false;
|
|
const context = run.contextSnapshot;
|
|
if (!context || (context.source !== "issue.comment" && context.source !== "issue.comment.reopen") || context.commentId !== comment.id
|
|
|| context.issueId !== session.issueId
|
|
|| (context.taskId != null && context.taskId !== session.issueId)
|
|
|| (run.issueId != null && run.issueId !== session.issueId)
|
|
|| (run.nativeIssueId != null && run.nativeIssueId !== session.issueId)) return false;
|
|
if (comment.companyId !== session.companyId || comment.issueId !== session.issueId
|
|
|| comment.authorType !== "user" || comment.authorUserId !== session.responsibleUserId
|
|
|| comment.authorAgentId || comment.createdByRunId || comment.derivedAuthorAgentId || comment.derivedCreatedByRunId
|
|
|| comment.derivedAuthorSource || comment.deletedAt || comment.sourceTrust != null
|
|
|| comment.presentation?.kind === "system_notice" || !comment.body.trim() || comment.body.trim() === "/new") return false;
|
|
// A later request by a different person cannot override this person's decline.
|
|
const deciders = [rejection.decidedByUserId, rejection.resolvedByUserId].filter(value => value != null);
|
|
if (!deciders.length || deciders.some(value => value !== session.responsibleUserId)
|
|
|| rejection.resolvedByAgentId || rejection.decidedByAgentId) return false;
|
|
const decisions = [rejection.decidedAt, rejection.resolvedAt].filter((value): value is Date => value != null);
|
|
const times = decisions.map(value => value.getTime());
|
|
const requestedAt = comment.createdAt.getTime(), startedAt = run.startedAt?.getTime();
|
|
return times.length > 0 && times.every(Number.isFinite) && Number.isFinite(requestedAt) && Number.isFinite(startedAt)
|
|
&& Math.max(...times) < requestedAt && requestedAt <= startedAt! && startedAt! <= Date.now();
|
|
}
|