mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-12 06:10:44 +01:00
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agent Chat answers a person's requests with a chat agent that reads API results and the chat guidance > - A decision card can be human-only and addressed to one person, and the teams catalog lists ready-made teams with their requirements > - When an agent tried to accept another person's card, the denial said only "human-only", so the agent told the asker to accept the card themselves > - When an agent recommended a catalog team, the guidance asked only for roles, so the agent left out the required skills > - This pull request makes the denial name the addressee as the only resolver, and asks agents to list a team's roles, required skills and configuration inputs > - The benefit is that chat answers send people to the right person and show what a team needs before install ## Linked Issues or Issue Description Refs #20206, #20220, #20239 **What happened** Alex wrote "I approve Riley's request." in Agent Chat. The pending card is human-only and addressed to Riley. The agent tried to record the decision and got `403 interaction_human_only` with only "This issue-thread interaction is human-only". The reply correctly said the card stays pending, but it then told Alex to "accept or decline that request" on the task. Alex cannot do that; only Riley can. In a second chat, a person asked "Do we have a ready-made team for building a website?". The agent recommended Product Engineering and its roles. It did not name the four required skills that the catalog returned in `requiredSkills`. **Expected behavior** When an agent cannot resolve a card that is addressed to another person, the reply names that person as the only one who can resolve it. The reply does not tell the asker to act on the card. When an agent recommends a catalog team, the reply names the team's roles, every required skill and every configuration input. It says that a catalog listing does not mean the accounts are configured. **Steps to reproduce** 1. As user Alex, create a `request_confirmation` card with `resolverPolicy: "human_only"` and `addresseeUserId` set to user Riley on Alex's chat task. 2. In that chat, write "I approve Riley's request." 3. Read the agent run. `resolve-from-comment` returns 403 `interaction_human_only` without the addressee. The reply tells Alex to accept or decline the card. 4. In a new chat, ask "Do we have a ready-made team for building a website?". 5. Read the reply. It names Product Engineering and its roles, but not its required skills. ## What Changed - `server/src/services/issue-thread-interaction-resolution.ts`: when an agent is denied on a human-only card that is addressed to a person other than the verified replying user, the denial says the card is addressed to that person, that only they can accept or decline it, that the denial is final, and that the agent must not ask the asker to act on the card. The details include `addresseeUserId`. Cards without an addressee keep the old message. - `server/src/routes/issues.ts`: the route adds the addressee's display name to that denial (`details.addresseeName` and the message). If the name lookup fails, the generic addressee text stays. - `server/src/services/agent-conversations.ts`: the teams catalog paragraph asks agents to name a recommended team's roles (`agentSlugs`), every required skill (`requiredSkills`, by ref) and every configuration input (`envInputs`), and to say that a listing does not mean accounts are configured. - Tests: evaluator unit tests, a route-level test for the named denial, and a directive assertion. ## Verification - `cd server && npx vitest run src/services/issue-thread-interaction-resolution.test.ts src/__tests__/agent-conversations.test.ts src/__tests__/issue-thread-interaction-routes.test.ts src/__tests__/confirmation-comment-resolution.test.ts`: 4 files, all pass. - `tsc --noEmit -p server/tsconfig.json`: passes. - The change is cherry-picked onto the eval branch `chat-eval/case-reply-completeness`. The live Runner E2E cells `questions-and-approvals.wrong-reviewer` and `teams.find-team` run there. ## Risks - Low risk. The resolver decision does not change: the same requests are allowed and denied. Only the denial text and details change for agents on addressed human-only cards. - The denial reads one user row for the display name. A failed read falls back to the generic text. - The chat directive is slightly longer, so each chat prompt uses a few more tokens. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), with 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>
430 lines
16 KiB
TypeScript
430 lines
16 KiB
TypeScript
import type {
|
|
IssueThreadInteractionCanonicalResolverPolicy,
|
|
IssueThreadInteractionResolverPolicy,
|
|
} from "@paperclipai/shared";
|
|
import { normalizeIssueThreadInteractionResolverPolicy } from "@paperclipai/shared";
|
|
import { HttpError } from "../errors.js";
|
|
|
|
export const ISSUE_THREAD_INTERACTION_RESOLUTION_DENIAL_CODES = [
|
|
"interaction_not_found",
|
|
"interaction_run_attribution_required",
|
|
"interaction_scope_denied",
|
|
"interaction_human_only",
|
|
"interaction_creator_excluded",
|
|
"interaction_addressee_mismatch",
|
|
"interaction_stale_target",
|
|
"interaction_superseded",
|
|
"interaction_already_resolved",
|
|
"interaction_issue_closed",
|
|
"interaction_governed_action_denied",
|
|
"review_policy_denied",
|
|
] as const;
|
|
|
|
export type IssueThreadInteractionResolutionDenialCode =
|
|
(typeof ISSUE_THREAD_INTERACTION_RESOLUTION_DENIAL_CODES)[number];
|
|
|
|
export type IssueThreadInteractionResolverActor =
|
|
| { type: "user"; userId: string }
|
|
| {
|
|
type: "agent";
|
|
agentId: string | null | undefined;
|
|
runId: string | null | undefined;
|
|
/**
|
|
* Set only by conversational confirmation resolution after it verified
|
|
* the bound user's latest comment. It never makes the agent a human.
|
|
*/
|
|
relayedHumanUserId?: string | null;
|
|
}
|
|
| { type: "system"; systemId: string };
|
|
|
|
export type IssueThreadInteractionResolverRestriction = {
|
|
policy: IssueThreadInteractionCanonicalResolverPolicy;
|
|
/**
|
|
* A server-resolved identity excluded by the binding flow in addition to the
|
|
* interaction creator. `null` means the binding requires an identity but it
|
|
* could not be resolved, so the evaluator must fail closed.
|
|
*/
|
|
excludedActor?: { type: "agent" | "user"; id: string } | null;
|
|
source?: "issue_review";
|
|
};
|
|
|
|
export type IssueThreadInteractionResolverAudienceInput = {
|
|
actor: IssueThreadInteractionResolverActor;
|
|
interaction: {
|
|
createdByAgentId?: string | null;
|
|
createdByUserId?: string | null;
|
|
sourceRunId?: string | null;
|
|
addresseeAgentId?: string | null;
|
|
addresseeUserId?: string | null;
|
|
effectiveResolverPolicy: IssueThreadInteractionResolverPolicy | string;
|
|
effectiveResolverPolicySource?: string | null;
|
|
resolverPolicyProvenance?: string | null;
|
|
};
|
|
/**
|
|
* A server-derived restriction owned by a binding flow such as issue review.
|
|
* It may only narrow the interaction's persisted effective audience.
|
|
*/
|
|
additionalRestriction?:
|
|
| IssueThreadInteractionCanonicalResolverPolicy
|
|
| IssueThreadInteractionResolverRestriction
|
|
| null;
|
|
governedAction?: boolean;
|
|
};
|
|
|
|
export type IssueThreadInteractionResolverAudienceDecision =
|
|
| {
|
|
allowed: true;
|
|
effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy;
|
|
reason: "allow_anyone" | "allow_addressee" | "allow_human" | "allow_system" | "allow_human_override"
|
|
| "allow_relayed_human";
|
|
}
|
|
| {
|
|
allowed: false;
|
|
effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy;
|
|
status: 403 | 422;
|
|
code: Extract<
|
|
IssueThreadInteractionResolutionDenialCode,
|
|
| "interaction_run_attribution_required"
|
|
| "interaction_human_only"
|
|
| "interaction_creator_excluded"
|
|
| "interaction_addressee_mismatch"
|
|
| "interaction_governed_action_denied"
|
|
| "review_policy_denied"
|
|
>;
|
|
message: string;
|
|
details?: Record<string, unknown>;
|
|
};
|
|
|
|
/**
|
|
* Canonicalize one stored resolver-policy value.
|
|
*
|
|
* Before provenance existed, `board_or_agents` excluded both the creator agent
|
|
* and the creating run, so a pre-migration row must stay `not_creator` rather
|
|
* than widening to canonical `anyone`. Every reader of the stored columns —
|
|
* hydration, the audience evaluator, and the attention feed — goes through here
|
|
* so none of them can disagree about a legacy row.
|
|
*/
|
|
export function canonicalizeStoredResolverPolicy(
|
|
policy: IssueThreadInteractionResolverPolicy | string,
|
|
resolverPolicyProvenance: string | null | undefined,
|
|
): IssueThreadInteractionCanonicalResolverPolicy {
|
|
if (resolverPolicyProvenance === "legacy_inherited_restriction" && policy === "board_or_agents") {
|
|
return "not_creator";
|
|
}
|
|
return normalizeIssueThreadInteractionResolverPolicy(policy as IssueThreadInteractionResolverPolicy);
|
|
}
|
|
|
|
function canonicalStoredPolicy(input: IssueThreadInteractionResolverAudienceInput["interaction"]) {
|
|
return canonicalizeStoredResolverPolicy(
|
|
input.effectiveResolverPolicy,
|
|
input.resolverPolicyProvenance,
|
|
);
|
|
}
|
|
|
|
function reviewRestrictionExcludesActor(
|
|
restriction: IssueThreadInteractionResolverRestriction | null | undefined,
|
|
actor: { type: "agent" | "user"; id: string },
|
|
) {
|
|
return restriction?.policy === "not_creator"
|
|
&& restriction.source === "issue_review"
|
|
&& (
|
|
restriction.excludedActor === null
|
|
|| (
|
|
restriction.excludedActor?.type === actor.type
|
|
&& restriction.excludedActor.id === actor.id
|
|
)
|
|
);
|
|
}
|
|
|
|
function reviewPolicyDeniedDecision(
|
|
effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy,
|
|
requesterKnown: boolean,
|
|
): Extract<IssueThreadInteractionResolverAudienceDecision, { allowed: false }> {
|
|
return {
|
|
allowed: false,
|
|
effectiveResolverPolicy,
|
|
status: 403,
|
|
code: "review_policy_denied",
|
|
message: requesterKnown
|
|
? "Review policy `not_creator` requires someone other than the writer who moved the issue into `in_review` to approve or reject it."
|
|
: "Review policy `not_creator` requires a different writer, but the review requester could not be determined.",
|
|
details: {
|
|
policy: "not_creator",
|
|
allowedActor: "writer_other_than_review_requester",
|
|
remediation: requesterKnown
|
|
? "Have another writer with issue write access submit the verdict, or change reviewPolicy to `anyone`."
|
|
: "Change reviewPolicy to `anyone`, or move the issue out of and back into `in_review` to record a requester before another writer submits the verdict.",
|
|
},
|
|
};
|
|
}
|
|
|
|
/**
|
|
* An agent may record a human-only confirmation only as the relay of a verified
|
|
* human decision, and only on a card it created with an explicit human_only
|
|
* request. Company caps (checked again at resolution by the caller), governed
|
|
* actions and binding-flow restrictions still require a direct human resolution.
|
|
*/
|
|
function relayedHumanDecisionAllowed(
|
|
input: IssueThreadInteractionResolverAudienceInput,
|
|
persistedPolicy: IssueThreadInteractionCanonicalResolverPolicy,
|
|
additionalRestriction: IssueThreadInteractionResolverRestriction | null | undefined,
|
|
) {
|
|
if (input.actor.type !== "agent") return false;
|
|
const userId = input.actor.relayedHumanUserId;
|
|
return Boolean(userId && input.actor.agentId)
|
|
&& !input.governedAction
|
|
&& persistedPolicy === "human_only"
|
|
// Only this agent's own explicit request; a company default, cap or a
|
|
// human-created card keeps its direct human resolution.
|
|
&& input.interaction.createdByAgentId === input.actor.agentId
|
|
&& input.interaction.resolverPolicyProvenance === "explicit"
|
|
&& input.interaction.effectiveResolverPolicySource === "requested"
|
|
&& !additionalRestriction
|
|
&& (!input.interaction.addresseeUserId || input.interaction.addresseeUserId === userId);
|
|
}
|
|
|
|
/**
|
|
* Denial for a human-only card addressed to one person. Agents relay it in
|
|
* chat, so it names who resolves the card. Only when the replying user is
|
|
* known to be someone else does it tell the agent not to send them to the card.
|
|
*/
|
|
export function addressedHumanOnlyDenialMessage(addresseeName: string | null | undefined, askerIsNotAddressee: boolean) {
|
|
const name = addresseeName?.trim() || null;
|
|
const message = `This issue-thread interaction is human-only and addressed to ${name ?? "one person, its addressee (addresseeUserId)"}. `
|
|
+ `Only ${name ?? "the addressee"} can accept or decline it from the card; no agent can resolve it for them.`;
|
|
if (!askerIsNotAddressee) return message;
|
|
return `${message} The person asking is not the addressee, and their chat message does not count as the addressee's decision. `
|
|
+ `This denial is final. Tell them the card waits on ${name ?? "the addressee"}, name ${name ? "them" : "the addressee"} as the only one who can resolve it, `
|
|
+ "and do not ask the person asking to accept or decline it.";
|
|
}
|
|
|
|
/**
|
|
* The single pure audience evaluator for issue-thread interaction resolution.
|
|
* Resource access, run validation, containment, and current-target checks must
|
|
* happen before this evaluator. Mutation routes must re-run it at use time.
|
|
*/
|
|
export function evaluateIssueThreadInteractionResolverAudience(
|
|
input: IssueThreadInteractionResolverAudienceInput,
|
|
): IssueThreadInteractionResolverAudienceDecision {
|
|
const persistedPolicy = canonicalStoredPolicy(input.interaction);
|
|
const additionalRestriction = typeof input.additionalRestriction === "string"
|
|
? { policy: input.additionalRestriction }
|
|
: input.additionalRestriction;
|
|
// human_only and not_creator are independent constraints, not a linear
|
|
// severity scale: their intersection means "a human other than the
|
|
// creator." Keep both predicates even though the persisted public policy
|
|
// enum can only name one of them.
|
|
const creatorExcluded =
|
|
persistedPolicy === "not_creator"
|
|
|| additionalRestriction?.policy === "not_creator";
|
|
const humanOnly =
|
|
Boolean(input.governedAction)
|
|
|| persistedPolicy === "human_only"
|
|
|| additionalRestriction?.policy === "human_only";
|
|
const effectiveResolverPolicy: IssueThreadInteractionCanonicalResolverPolicy = humanOnly
|
|
? "human_only"
|
|
: creatorExcluded
|
|
? "not_creator"
|
|
: "anyone";
|
|
|
|
if (input.actor.type === "system") {
|
|
return { allowed: true, effectiveResolverPolicy, reason: "allow_system" };
|
|
}
|
|
|
|
if (input.actor.type === "user") {
|
|
if (
|
|
input.interaction.addresseeUserId
|
|
&& input.interaction.addresseeUserId !== input.actor.userId
|
|
) {
|
|
return {
|
|
allowed: false,
|
|
effectiveResolverPolicy,
|
|
status: 403,
|
|
code: "interaction_addressee_mismatch",
|
|
message: "Only the addressed user may resolve this issue-thread interaction",
|
|
};
|
|
}
|
|
if (
|
|
creatorExcluded
|
|
&& input.interaction.createdByUserId === input.actor.userId
|
|
) {
|
|
return {
|
|
allowed: false,
|
|
effectiveResolverPolicy,
|
|
status: 403,
|
|
code: "interaction_creator_excluded",
|
|
message: "This issue-thread interaction requires a resolver other than its creator",
|
|
};
|
|
}
|
|
if (reviewRestrictionExcludesActor(additionalRestriction, {
|
|
type: "user",
|
|
id: input.actor.userId,
|
|
})) {
|
|
return reviewPolicyDeniedDecision(
|
|
effectiveResolverPolicy,
|
|
additionalRestriction?.excludedActor !== null,
|
|
);
|
|
}
|
|
return {
|
|
allowed: true,
|
|
effectiveResolverPolicy,
|
|
reason: input.interaction.addresseeUserId
|
|
? "allow_addressee"
|
|
: input.interaction.addresseeAgentId
|
|
? "allow_human_override"
|
|
: "allow_human",
|
|
};
|
|
}
|
|
|
|
if (!input.actor.agentId || !input.actor.runId?.trim()) {
|
|
return {
|
|
allowed: false,
|
|
effectiveResolverPolicy,
|
|
status: 422,
|
|
code: "interaction_run_attribution_required",
|
|
message: "A valid authenticated agent run is required to resolve this issue-thread interaction",
|
|
};
|
|
}
|
|
|
|
if (input.governedAction) {
|
|
return {
|
|
allowed: false,
|
|
effectiveResolverPolicy,
|
|
status: 403,
|
|
code: "interaction_governed_action_denied",
|
|
message: "This interaction is bound to a governed action that requires independent authorization",
|
|
};
|
|
}
|
|
|
|
if (effectiveResolverPolicy === "human_only") {
|
|
if (relayedHumanDecisionAllowed(input, persistedPolicy, additionalRestriction)) {
|
|
return { allowed: true, effectiveResolverPolicy, reason: "allow_relayed_human" };
|
|
}
|
|
const addresseeUserId = input.interaction.addresseeUserId;
|
|
if (addresseeUserId && input.actor.type === "agent") {
|
|
// Only a verified replying user is known; /accept and /reject carry none.
|
|
const askerIsNotAddressee = Boolean(input.actor.relayedHumanUserId) && input.actor.relayedHumanUserId !== addresseeUserId;
|
|
return {
|
|
allowed: false,
|
|
effectiveResolverPolicy,
|
|
status: 403,
|
|
code: "interaction_human_only",
|
|
message: addressedHumanOnlyDenialMessage(null, askerIsNotAddressee),
|
|
details: { addresseeUserId, askerIsNotAddressee },
|
|
};
|
|
}
|
|
return {
|
|
allowed: false,
|
|
effectiveResolverPolicy,
|
|
status: 403,
|
|
code: "interaction_human_only",
|
|
message: "This issue-thread interaction is human-only",
|
|
};
|
|
}
|
|
|
|
if (input.interaction.addresseeUserId) {
|
|
return {
|
|
allowed: false,
|
|
effectiveResolverPolicy,
|
|
status: 403,
|
|
code: "interaction_addressee_mismatch",
|
|
message: "This issue-thread interaction is addressed to a specific user",
|
|
};
|
|
}
|
|
|
|
if (
|
|
input.interaction.addresseeAgentId
|
|
&& input.interaction.addresseeAgentId !== input.actor.agentId
|
|
) {
|
|
return {
|
|
allowed: false,
|
|
effectiveResolverPolicy,
|
|
status: 403,
|
|
code: "interaction_addressee_mismatch",
|
|
message: "Only the addressed agent or an authorized human may resolve this issue-thread interaction",
|
|
};
|
|
}
|
|
|
|
if (
|
|
creatorExcluded
|
|
&& (
|
|
input.interaction.createdByAgentId === input.actor.agentId
|
|
|| (
|
|
Boolean(input.interaction.sourceRunId)
|
|
&& input.interaction.sourceRunId === input.actor.runId
|
|
)
|
|
)
|
|
) {
|
|
return {
|
|
allowed: false,
|
|
effectiveResolverPolicy,
|
|
status: 403,
|
|
code: "interaction_creator_excluded",
|
|
message: "This issue-thread interaction requires a resolver other than its creator or creating run",
|
|
};
|
|
}
|
|
|
|
if (reviewRestrictionExcludesActor(additionalRestriction, {
|
|
type: "agent",
|
|
id: input.actor.agentId,
|
|
})) {
|
|
return reviewPolicyDeniedDecision(
|
|
effectiveResolverPolicy,
|
|
additionalRestriction?.excludedActor !== null,
|
|
);
|
|
}
|
|
|
|
return {
|
|
allowed: true,
|
|
effectiveResolverPolicy,
|
|
reason: input.interaction.addresseeAgentId ? "allow_addressee" : "allow_anyone",
|
|
};
|
|
}
|
|
|
|
export function issueThreadInteractionResolutionError(
|
|
status: number,
|
|
code: IssueThreadInteractionResolutionDenialCode,
|
|
message: string,
|
|
details: Record<string, unknown> = {},
|
|
) {
|
|
return new HttpError(status, message, { code, ...details });
|
|
}
|
|
|
|
export function assertIssueThreadInteractionResolverAudience(
|
|
input: IssueThreadInteractionResolverAudienceInput,
|
|
) {
|
|
const decision = evaluateIssueThreadInteractionResolverAudience(input);
|
|
if (!decision.allowed) {
|
|
throw issueThreadInteractionResolutionError(
|
|
decision.status,
|
|
decision.code,
|
|
decision.message,
|
|
{ effectiveResolverPolicy: decision.effectiveResolverPolicy, ...(decision.details ?? {}) },
|
|
);
|
|
}
|
|
return decision;
|
|
}
|
|
|
|
/**
|
|
* Resolves agent-owned attention with the same audience rules used by mutation
|
|
* routes. Run attribution is deliberately represented by the interaction's
|
|
* source run for the creator and by an opaque non-source run for other agents;
|
|
* the real run is still authenticated at mutation time.
|
|
*/
|
|
export function issueThreadInteractionAttentionAgentAllowed(input: {
|
|
agentId: string;
|
|
interaction: IssueThreadInteractionResolverAudienceInput["interaction"];
|
|
additionalRestriction?: IssueThreadInteractionResolverAudienceInput["additionalRestriction"];
|
|
governedAction?: boolean;
|
|
}) {
|
|
const attentionRunId = input.interaction.createdByAgentId === input.agentId
|
|
? input.interaction.sourceRunId ?? "attention-owner-creator-run"
|
|
: "attention-owner-non-source-run";
|
|
return evaluateIssueThreadInteractionResolverAudience({
|
|
actor: { type: "agent", agentId: input.agentId, runId: attentionRunId },
|
|
interaction: input.interaction,
|
|
additionalRestriction: input.additionalRestriction,
|
|
governedAction: input.governedAction,
|
|
}).allowed;
|
|
}
|