diff --git a/packages/paperclip-runner/docs/capability-semantic-tools.md b/packages/paperclip-runner/docs/capability-semantic-tools.md index dff75b3b022..ee5ae11d7be 100644 --- a/packages/paperclip-runner/docs/capability-semantic-tools.md +++ b/packages/paperclip-runner/docs/capability-semantic-tools.md @@ -26,7 +26,7 @@ list, not a disposition — they are never exposed as tools. See [capability disposition](capability-disposition.md) and [authorization and exposure](capability-authorization-and-exposure.md). -The catalog holds **38 tools**: 14 always-agent tools and 24 optional tools +The catalog holds **39 tools**: 14 always-agent tools and 25 optional tools across 10 groups. `finish_task`, `block_task`, `request_review`, `write_document`, `request_human_input`, `register_deliverable`, `inspect_operation_result`. -### Optional tools (24), by group +### Optional tools (25), by group | Group | Tools | | --- | --- | | discovery | `search_tasks`, `list_agents`, `list_projects`, `list_goals` | -| delegation_dependencies | `create_task`, `set_dependencies` | +| delegation_dependencies | `create_task`, `reassign_task`, `set_dependencies` | | governance | `list_approvals`, `request_approval`, `decide_approval`, `comment_on_approval` | | cases | `list_cases`, `upsert_case` | | workspace_runtime | `get_workspace_runtime`, `control_workspace_service` | @@ -96,3 +96,24 @@ that every optional group is present. - [Authorization and exposure](capability-authorization-and-exposure.md) - [Mock ControlPlanePort](capability-mock-control-plane-port.md) - [Eval conformance](capability-eval-conformance.md) + +## Reassigning existing tasks + +`reassign_task` moves another existing company task to an eligible agent. Read +`search_tasks` first and send the observed `expectedAssigneeActorId` (nullable) +and `expectedStatusVersion`, the new `assigneeActorId`, a handoff `reason`, and a +stable `idempotencyKey`. It requires `delegation:tasks:assign` and production +`tasks:assign` authorization in standard mode. The same task retains its +documents, dependencies, scope, priority, and history. + +The old execution and any live goal must stop before ownership changes. Active +work returns to todo; backlog and blocked work retain their status and are not +started. Assignment changes advance the observed version, including ordinary +API changes, to reject stale handoffs. A durable audit receipt makes retries +idempotent across caller runs; retrying after failed dispatch repairs the wake +with a stable key and an owner/status/version guard. + +The current caller task, conversations, human-assigned tasks, completed or +cancelled tasks, and pending reviews cannot be reassigned with this tool. Use +`create_task` to delegate new work from the current task. A stop failure or +concurrent ownership change fails without applying the handoff. diff --git a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json index 4c4c24b0180..191865ceeed 100644 --- a/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json +++ b/packages/paperclip-runner/generated/capability/semantic-tool-contracts.json @@ -1 +1 @@ -[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a durable human question or approval card on the current Paperclip task bound to this run; Paperclip renders it and authenticates the response. Use questions with continuationPolicy='wake_assignee' when an answer is needed, including otherwise tool-free chat turns. Supply a stable idempotencyKey and reuse it on retries. For one question at a time, ask only the next unanswered question and wait for its real answer. Never infer answers, answer your own card, or treat clarification as approval. Preserve existing review gates. Call this tool before claiming a question was asked; if creation fails, report the failure. Do not fabricate answer links or Markdown buttons, post duplicate cards, or substitute call_api. Use payload.questions for choices and payload.questionSet for text fields; see the payload schema for formats.","inputSchema":{"additionalProperties":false,"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For interactionKind='questions', use version:1 and questions:[{id,prompt,selectionMode:'single'|'multi',required?,options:[{id,label,description?,freeText?}]}]. Choice questions need at least two distinct meaningful options. For an open-ended text answer, ALSO include questionSet:{schema:'paperclip.question_set.v1',questions:[{id,prompt,answerMode:'text',required?}]} with no options or customAnswer in its text questions. Keep matching IDs/prompts in both arrays; the required compatibility questions entry uses selectionMode:'single' and options:[{id:'describe',label:'Your answer',freeText:true}]. Without questionSet this incorrectly renders as a one-option choice. Never use a lone Other or describe option as the presentation. Option keys are id/label, not value. For confirmation, payload may be {}. Keep IDs stable across retries.","type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide an approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to an approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","enum":["folder","rocket","code","terminal","database","globe","package","boxes","box","layers","briefcase","compass","target","flame","zap","star","bug","wrench","hammer","lightbulb","sparkles","shield","lock","search","cog","brain","cpu","git-branch","file-code","puzzle","gem","atom","heart","mail","message-square","crown","radar","telescope","hexagon",null],"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"maxLength":2000,"pattern":"^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Inspect available company projects before selecting a project for new work.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a reusable single-file skill in the company library. Supply a complete SKILL.md whose name and description match the inputs. This saves the skill and shows a card; it does not assign the skill to any agent. Reuse idempotencyKey on retries.","inputSchema":{"additionalProperties":false,"properties":{"description":{"maxLength":2000,"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"description":"Complete SKILL.md including name and description frontmatter and substantive instructions.","maxLength":200000,"minLength":1,"type":"string"},"name":{"description":"Lowercase skill name, matching SKILL.md frontmatter.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"},"slug":{"description":"Optional; must equal name.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"}},"required":["idempotencyKey","name","description","markdown"],"type":"object"},"name":"create_skill","outputSchema":{"additionalProperties":true,"type":"object"}}] +[{"annotations":{"exposure":"always","operationId":"get_task_context","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the active task and actor, including the exact approved Markdown revision when this issue has an accepted plan.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_task_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"get_task_history","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded comments on the active task.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":[],"type":"object"},"name":"get_task_history","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_documents","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List revisioned documents on the active task.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_documents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"read_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read the current revision of one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"}},"required":["key"],"type":"object"},"name":"read_document","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"list_document_revisions","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read bounded revision history for one active-task document.","inputSchema":{"additionalProperties":false,"properties":{"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"}},"required":["key"],"type":"object"},"name":"list_document_revisions","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"always","operationId":"report_progress","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append a durable progress comment to the active task.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Multiline progress update.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"report_progress","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"answer_status_question","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Append the answer to a status-only wake without changing task disposition.","inputSchema":{"additionalProperties":false,"properties":{"body":{"description":"Concise status answer.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","body"],"type":"object"},"name":"answer_status_question","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"write_document","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create or update an active-task document with optimistic revision safety.","inputSchema":{"additionalProperties":false,"properties":{"baseRevisionId":{"description":"Current revision id, or null when creating.","maxLength":20000,"type":["string","null"]},"body":{"description":"Markdown document body.","maxLength":200000,"minLength":1,"type":"string"},"changeSummary":{"description":"Optional revision summary.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"key":{"description":"Stable issue-document key.","maxLength":120,"minLength":1,"type":"string"},"title":{"description":"Document title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","key","title","body","baseRevisionId"],"type":"object"},"name":"write_document","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_human_input","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a durable human question or approval card on the current Paperclip task bound to this run; Paperclip renders it and authenticates the response. Use questions with continuationPolicy='wake_assignee' when an answer is needed, including otherwise tool-free chat turns. Supply a stable idempotencyKey and reuse it on retries. For one question at a time, ask only the next unanswered question and wait for its real answer. Never infer answers, answer your own card, or treat clarification as approval. Preserve existing review gates. Call this tool before claiming a question was asked; if creation fails, report the failure. Do not fabricate answer links or Markdown buttons, post duplicate cards, or substitute call_api. Use payload.questions for choices and payload.questionSet for text fields; see the payload schema for formats.","inputSchema":{"additionalProperties":false,"properties":{"continuationPolicy":{"enum":["none","wake_assignee","wake_assignee_on_accept"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"interactionKind":{"enum":["confirmation","checkbox","questions","suggest_tasks","item_verdicts"]},"payload":{"additionalProperties":true,"description":"Kind-specific interaction data. For interactionKind='questions', use version:1 and questions:[{id,prompt,selectionMode:'single'|'multi',required?,options:[{id,label,description?,freeText?}]}]. Choice questions need at least two distinct meaningful options. For an open-ended text answer, ALSO include questionSet:{schema:'paperclip.question_set.v1',questions:[{id,prompt,answerMode:'text',required?}]} with no options or customAnswer in its text questions. Keep matching IDs/prompts in both arrays; the required compatibility questions entry uses selectionMode:'single' and options:[{id:'describe',label:'Your answer',freeText:true}]. Without questionSet this incorrectly renders as a one-option choice. Never use a lone Other or describe option as the presentation. Option keys are id/label, not value. For confirmation, payload may be {}. Keep IDs stable across retries.","type":"object"},"prompt":{"description":"Question or decision prompt.","maxLength":10000,"minLength":1,"type":"string"},"targetRevisionId":{"description":"Optional bound document revision.","maxLength":20000,"type":["string","null"]},"title":{"description":"Interaction card title.","maxLength":300,"minLength":1,"type":"string"}},"required":["idempotencyKey","interactionKind","title","prompt","continuationPolicy"],"type":"object"},"name":"request_human_input","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"register_deliverable","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Register attachment metadata and its artifact work product without credentials or bytes in the tool result.","inputSchema":{"additionalProperties":false,"properties":{"byteSize":{"maximum":100000000,"minimum":0,"type":"integer"},"contentRef":{"description":"Opaque package-local content reference.","maxLength":2000,"minLength":1,"type":"string"},"contentType":{"description":"Media type.","maxLength":200,"minLength":1,"type":"string"},"filename":{"description":"Display filename.","maxLength":500,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"sha256":{"pattern":"^[a-fA-F0-9]{64}$","type":"string"},"title":{"description":"Work-product title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","filename","contentType","byteSize","sha256","contentRef","title"],"type":"object"},"name":"register_deliverable","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"finish_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Finish the active task with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Completion summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"finish_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"block_task","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Block the active task with a durable reason and optional first-class dependencies.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Internal task ids that block this task.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Block reason.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","reason"],"type":"object"},"name":"block_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"always","operationId":"request_review","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Move the active task to review with a durable summary.","inputSchema":{"additionalProperties":false,"properties":{"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"summary":{"description":"Review handoff summary.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","summary"],"type":"object"},"name":"request_review","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_agents","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List redacted actor profiles.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_agents","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_agent","requiredClaims":["discovery:agents:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one redacted actor profile.","inputSchema":{"additionalProperties":false,"properties":{"actorId":{"description":"Actor id.","maxLength":200,"minLength":1,"type":"string"}},"required":["actorId"],"type":"object"},"name":"get_agent","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_tasks","requiredClaims":["discovery:tasks:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Search tasks by text and status within the run company.","inputSchema":{"additionalProperties":false,"properties":{"limit":{"default":50,"maximum":200,"minimum":1,"type":"integer"},"query":{"maxLength":500,"type":"string"},"statuses":{"items":{"enum":["backlog","todo","in_progress","in_review","done","blocked","cancelled"]},"maxItems":7,"type":"array","uniqueItems":true}},"required":[],"type":"object"},"name":"search_tasks","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_approvals","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List approvals in the run company.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_approvals","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval without protected data.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_approval_context","requiredClaims":["governance:approvals:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read one approval, its comments, and linked tasks.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"}},"required":["approvalId"],"type":"object"},"name":"get_approval_context","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"get_workspace_runtime","requiredClaims":["workspace:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Read active-task workspace services.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"get_workspace_runtime","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"control_workspace_service","requiredClaims":["workspace:control"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Start, stop, or fault one active-task workspace service.","inputSchema":{"additionalProperties":false,"properties":{"action":{"enum":["start","stop","fail"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"serviceId":{"description":"Workspace service id.","maxLength":200,"minLength":1,"type":"string"},"url":{"description":"Optional service URL.","maxLength":20000,"type":["string","null"]}},"required":["idempotencyKey","serviceId","action"],"type":"object"},"name":"control_workspace_service","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"set_dependencies","requiredClaims":["dependencies:write"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Replace the active task's first-class blocker set.","inputSchema":{"additionalProperties":false,"properties":{"blockedByTaskIds":{"description":"Replacement blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","blockedByTaskIds"],"type":"object"},"name":"set_dependencies","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_task","requiredClaims":["delegation:tasks:create"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project task from a conversation, or a child from an ordinary task. Persist initialPlan before execution.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"Optional agent assignee. Omit to assign the current agent.","maxLength":20000,"type":["string","null"]},"blockedByTaskIds":{"description":"Initial blocker task ids.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"description":{"description":"Child task description.","maxLength":20000,"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"initialPlan":{"description":"Relevant markdown plan saved on the new task before execution starts.","maxLength":200000,"type":["string","null"]},"priority":{"enum":["critical","high","medium","low"]},"projectId":{"description":"Project ID for the task.","type":["string","null"]},"title":{"description":"Child task title.","maxLength":500,"minLength":1,"type":"string"}},"required":["idempotencyKey","title"],"type":"object"},"name":"create_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"},"task":{"additionalProperties":false,"properties":{"assigneeActorId":{"type":["string","null"]},"id":{"minLength":1,"type":"string"},"identifier":{"type":["string","null"]},"parentId":{"minLength":1,"type":["string","null"]},"projectId":{"minLength":1,"type":["string","null"]},"status":{"minLength":1,"type":"string"}},"required":["id","identifier","parentId","status","assigneeActorId"],"type":"object"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds","task"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"request_approval","requiredClaims":["governance:approvals:request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a governed approval and waiting posture.","inputSchema":{"additionalProperties":false,"properties":{"approvalType":{"description":"Stable approval type.","maxLength":200,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","approvalType","payload"],"type":"object"},"name":"request_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"decide_approval","requiredClaims":["governance:approvals:decide"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Decide an approval as an explicitly authorized approver.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"decision":{"enum":["approved","rejected","cancelled"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"note":{"description":"Decision note.","maxLength":20000,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","decision","note"],"type":"object"},"name":"decide_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"comment_on_approval","requiredClaims":["governance:approvals:comment"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Add a durable comment to an approval.","inputSchema":{"additionalProperties":false,"properties":{"approvalId":{"description":"Approval id.","maxLength":200,"minLength":1,"type":"string"},"body":{"description":"Approval comment.","maxLength":20000,"minLength":1,"type":"string"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"}},"required":["idempotencyKey","approvalId","body"],"type":"object"},"name":"comment_on_approval","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"schedule_wake","requiredClaims":["control_plane:wakes"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Schedule a deterministic continuation wake.","inputSchema":{"additionalProperties":false,"properties":{"delayTicks":{"maximum":10000,"minimum":1,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"payload":{"additionalProperties":true,"type":"object"},"reason":{"enum":["manual","issue_commented","interaction_resolved","approval_resolved","blockers_resolved","scheduled_retry","resume"]}},"required":["idempotencyKey","reason","delayTicks"],"type":"object"},"name":"schedule_wake","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}},{"annotations":{"exposure":"optional","operationId":"generic_api_request","requiredClaims":["test:generic_api_request"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Test-only escape hatch. Disabled unless the scenario and explicit claim both enable it.","inputSchema":{"additionalProperties":false,"properties":{"body":{"additionalProperties":true,"type":"object"},"method":{"enum":["GET","POST","PATCH"]},"path":{"maxLength":500,"pattern":"^/mock/","type":"string"}},"required":["method","path"],"type":"object"},"name":"generic_api_request","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"search_api","requiredClaims":["api:discover"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: discover Paperclip API operations when the available dedicated tools cannot express the task. Prefer dedicated tools for common operations; do not search before using them.","inputSchema":{"additionalProperties":false,"properties":{"cursor":{"maxLength":200,"type":"string"},"limit":{"default":5,"maximum":10,"minimum":1,"type":"integer"},"query":{"maxLength":500,"minLength":1,"type":"string"}},"required":["query"],"type":"object"},"name":"search_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"call_api","requiredClaims":["api:call"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Fallback only: call a discovered Paperclip API operation when dedicated tools lack the required operation or parameters. Uses your existing permissions. Prefer dedicated tools; never bypass a denial or runner lifecycle tool.","inputSchema":{"additionalProperties":false,"properties":{"body":{"anyOf":[{"additionalProperties":true,"type":"object"},{"items":{},"type":"array"},{"type":"string"},{"type":"number"},{"type":"boolean"},{"type":"null"}],"description":"Request value matching the discovered schema. For JSON object or array requests, pass the object or array directly, never a JSON-encoded string. Strings are for text bodies or endpoints whose schema explicitly accepts a string."},"contentType":{"maxLength":120,"type":"string"},"files":{"items":{"additionalProperties":false,"oneOf":[{"properties":{"artifactId":{}},"required":["artifactId"]},{"properties":{"path":{}},"required":["path"]}],"properties":{"artifactId":{"type":"string"},"field":{"type":"string"},"path":{"description":"File relative to the active issue workspace. Remote files must first be uploaded as an artifact.","type":"string"}},"type":"object"},"maxItems":10,"type":"array"},"operationId":{"description":"Exact operationId returned by search_api, for example GET /api/projects/{id}. Do not guess identifiers.","maxLength":500,"minLength":1,"type":"string"},"pathParams":{"additionalProperties":{"type":"string"},"type":"object"},"query":{"additionalProperties":true,"type":"object"}},"required":["operationId"],"type":"object"},"name":"call_api","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_project","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a project after considering existing projects and available repositories. repositoryIds and repositoryUrls accept multiple existing repositories. Use HTTPS GitHub repositoryUrls when an accessible repo is not in the catalog; this registers project repositories, not remote GitHub repositories. Non-code projects may omit repositories. Cannot combine repositoryIds/repositoryUrls with workspace. Reuse the idempotency key on retries.","inputSchema":{"additionalProperties":false,"properties":{"archivedAt":{"description":"Archive timestamp.","maxLength":20000,"type":["string","null"]},"color":{"description":"Project color.","maxLength":20000,"type":["string","null"]},"description":{"description":"Project outcome and context.","maxLength":20000,"type":["string","null"]},"env":{"additionalProperties":true,"type":"object"},"executionWorkspacePolicy":{"additionalProperties":true,"type":"object"},"goalId":{"description":"Goal ID.","maxLength":20000,"type":["string","null"]},"goalIds":{"description":"Goal IDs.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"icon":{"description":"Project icon.","enum":["folder","rocket","code","terminal","database","globe","package","boxes","box","layers","briefcase","compass","target","flame","zap","star","bug","wrench","hammer","lightbulb","sparkles","shield","lock","search","cog","brain","cpu","git-branch","file-code","puzzle","gem","atom","heart","mail","message-square","crown","radar","telescope","hexagon",null],"type":["string","null"]},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"leadAgentId":{"description":"Lead agent ID.","maxLength":20000,"type":["string","null"]},"name":{"description":"Project name.","maxLength":500,"minLength":1,"type":"string"},"repositoryIds":{"description":"Authorized repository IDs from list_project_repositories; may contain multiple repositories.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"repositoryUrls":{"description":"Existing HTTPS GitHub repository URLs, including repos absent from the catalog.","items":{"maxLength":2000,"pattern":"^https://github\\.com/(?!\\.{1,2}/)[A-Za-z0-9_.-]+/(?!\\.{1,2}/?$)[A-Za-z0-9_.-]+/?$","type":"string"},"maxItems":100,"type":"array"},"status":{"enum":["backlog","planned","in_progress","completed","cancelled"]},"targetDate":{"description":"Target date.","maxLength":20000,"type":["string","null"]},"workspace":{"additionalProperties":true,"type":"object"}},"required":["idempotencyKey","name"],"type":"object"},"name":"create_project","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_project_repositories","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"List authorized repositories with stable IDs and names. Consider appropriate repositories before creating a project; never invent IDs.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_project_repositories","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"list_projects","requiredClaims":["discovery:projects:read"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Inspect available company projects before selecting a project for new work.","inputSchema":{"additionalProperties":false,"properties":{},"required":[],"type":"object"},"name":"list_projects","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"create_skill","requiredClaims":[],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Create a reusable single-file skill in the company library. Supply a complete SKILL.md whose name and description match the inputs. This saves the skill and shows a card; it does not assign the skill to any agent. Reuse idempotencyKey on retries.","inputSchema":{"additionalProperties":false,"properties":{"description":{"maxLength":2000,"minLength":1,"type":"string"},"idempotencyKey":{"maxLength":240,"minLength":1,"type":"string"},"markdown":{"description":"Complete SKILL.md including name and description frontmatter and substantive instructions.","maxLength":200000,"minLength":1,"type":"string"},"name":{"description":"Lowercase skill name, matching SKILL.md frontmatter.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"},"slug":{"description":"Optional; must equal name.","maxLength":120,"minLength":1,"pattern":"^[a-z0-9]+(?:-[a-z0-9]+)*$","type":"string"}},"required":["idempotencyKey","name","description","markdown"],"type":"object"},"name":"create_skill","outputSchema":{"additionalProperties":true,"type":"object"}},{"annotations":{"exposure":"optional","operationId":"reassign_task","requiredClaims":["delegation:tasks:assign"],"semanticContract":"paperclip.semantic-tool.v1","version":1},"description":"Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run’s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.","inputSchema":{"additionalProperties":false,"properties":{"assigneeActorId":{"description":"New company agent ID from list_agents.","minLength":1,"type":"string"},"expectedAssigneeActorId":{"description":"Current assigneeAgentId from search_tasks; null means unassigned.","type":["string","null"]},"expectedStatusVersion":{"description":"Current statusVersion from search_tasks.","minimum":0,"type":"integer"},"idempotencyKey":{"description":"Caller-stable retry key.","maxLength":240,"minLength":1,"type":"string"},"reason":{"description":"Why the task should move and context for the new owner.","maxLength":20000,"minLength":1,"type":"string"},"taskId":{"description":"Existing task ID from search_tasks.","minLength":1,"type":"string"}},"required":["idempotencyKey","taskId","assigneeActorId","expectedAssigneeActorId","expectedStatusVersion","reason"],"type":"object"},"name":"reassign_task","outputSchema":{"additionalProperties":false,"properties":{"commandId":{"description":"Stable command identifier.","maxLength":200,"minLength":1,"type":"string"},"disposition":{"enum":["applied","duplicate"]},"entityRefs":{"description":"Entities affected by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"scheduledWakeIds":{"description":"Wake identifiers scheduled by the operation.","items":{"minLength":1,"type":"string"},"maxItems":200,"type":"array","uniqueItems":true},"stateRevision":{"minimum":0,"type":"integer"}},"required":["commandId","disposition","stateRevision","entityRefs","scheduledWakeIds"],"type":"object"}}] diff --git a/packages/paperclip-runner/generated/semantic-action-catalog.json b/packages/paperclip-runner/generated/semantic-action-catalog.json index 90644e83e5e..a4d56399559 100644 --- a/packages/paperclip-runner/generated/semantic-action-catalog.json +++ b/packages/paperclip-runner/generated/semantic-action-catalog.json @@ -1522,6 +1522,119 @@ "title": "Control workspace service", "version": 1 }, + { + "allowedModes": [ + "standard", + "skill_test" + ], + "description": "Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run’s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.", + "effect": "write", + "inputSchema": { + "additionalProperties": false, + "properties": { + "assigneeActorId": { + "description": "New company agent ID from list_agents.", + "minLength": 1, + "type": "string" + }, + "expectedAssigneeActorId": { + "description": "Current assigneeAgentId from search_tasks; null means unassigned.", + "type": [ + "string", + "null" + ] + }, + "expectedStatusVersion": { + "description": "Current statusVersion from search_tasks.", + "minimum": 0, + "type": "integer" + }, + "idempotencyKey": { + "description": "Caller-stable retry key.", + "maxLength": 240, + "minLength": 1, + "type": "string" + }, + "reason": { + "description": "Why the task should move and context for the new owner.", + "maxLength": 20000, + "minLength": 1, + "type": "string" + }, + "taskId": { + "description": "Existing task ID from search_tasks.", + "minLength": 1, + "type": "string" + } + }, + "required": [ + "idempotencyKey", + "taskId", + "assigneeActorId", + "expectedAssigneeActorId", + "expectedStatusVersion", + "reason" + ], + "type": "object" + }, + "operationId": "reassign_task", + "outputSchema": { + "additionalProperties": false, + "properties": { + "commandId": { + "description": "Stable command identifier.", + "maxLength": 200, + "minLength": 1, + "type": "string" + }, + "disposition": { + "enum": [ + "applied", + "duplicate" + ] + }, + "entityRefs": { + "description": "Entities affected by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "scheduledWakeIds": { + "description": "Wake identifiers scheduled by the operation.", + "items": { + "minLength": 1, + "type": "string" + }, + "maxItems": 200, + "type": "array", + "uniqueItems": true + }, + "stateRevision": { + "minimum": 0, + "type": "integer" + } + }, + "required": [ + "commandId", + "disposition", + "stateRevision", + "entityRefs", + "scheduledWakeIds" + ], + "type": "object" + }, + "placement": "optional", + "requiredClaims": [ + "delegation:tasks:assign" + ], + "schema": "paperclip.semantic-action.v1", + "title": "Reassign task", + "version": 1 + }, { "allowedModes": [ "standard", diff --git a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json index 4092923f2b9..acc277d67ec 100644 --- a/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json +++ b/packages/paperclip-runner/protocol/fixtures/evals/native-execution-seeded.json @@ -24,7 +24,7 @@ "prpVersion": 1, "nativeExecutionVersion": 1, "catalogVersion": 1, - "catalogSha256": "sha256:cf243ac6e38f931e556db65b7593f7ed58ffb4d0a2543c90219b0cc723840196", + "catalogSha256": "sha256:6b9eeb1ef2fe014bacc9d1acbb443ffdbe9588167c5b808e2ff6f5e6add20a33", "driverContractVersion": 1, "driverKind": "paperclip-deterministic", "driverVersion": "1.0.0" diff --git a/packages/paperclip-runner/protocol/manifest.json b/packages/paperclip-runner/protocol/manifest.json index fc4661a127a..9c6ed049813 100644 --- a/packages/paperclip-runner/protocol/manifest.json +++ b/packages/paperclip-runner/protocol/manifest.json @@ -160,7 +160,7 @@ }, { "path": "fixtures/evals/native-execution-seeded.json", - "sha256": "8a86a398f09ed822e7fd9acbbcd08a007921562ad21df4306bb47c45e32d0b60", + "sha256": "142abbf9009e5d48a1bc91287af1dc15866c34439d587dee9d06eb31ce77d07c", "expectation": "accept", "compatibilityCase": "canonical" }, diff --git a/packages/paperclip-runner/spec/capability/protocol-coverage.json b/packages/paperclip-runner/spec/capability/protocol-coverage.json index b3de1393ab4..7fd0fff92c9 100644 --- a/packages/paperclip-runner/spec/capability/protocol-coverage.json +++ b/packages/paperclip-runner/spec/capability/protocol-coverage.json @@ -7,7 +7,7 @@ "src/scenarios/scenario-plan.ts" ], "counts": { - "actions": 46, + "actions": 47, "legacyRequirements": 106 }, "actions": [ @@ -1426,6 +1426,26 @@ "src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity" ] }, + { + "id": "reassign_task", + "ownership": "optional_agent_tool", + "surfaces": [ + "scenario", + "live" + ], + "legacyAliases": [], + "contractCase": "protocol-action:reassign_task", + "contractOwner": "src/catalog/protocol-action-contracts.test.ts::reassign_task has a schema-valid canonical example and every declared projection", + "legacyBehavioralCases": [], + "deterministicCases": [ + "protocol-action:reassign_task" + ], + "legacyRequirementCases": [], + "deterministicOwners": [ + "src/catalog/protocol-action-contracts.test.ts::reassign_task has a schema-valid canonical example and every declared projection", + "src/scenarios/scenario-explorer.test.ts::renders every scenario with exposure, control plane, authorization, diff, and parity" + ] + }, { "id": "sync_company_skills", "ownership": "optional_agent_tool", diff --git a/packages/paperclip-runner/spec/operation-groups/source.json b/packages/paperclip-runner/spec/operation-groups/source.json index f5aa9d84aea..ea704ff57c7 100644 --- a/packages/paperclip-runner/spec/operation-groups/source.json +++ b/packages/paperclip-runner/spec/operation-groups/source.json @@ -57,8 +57,8 @@ }, { "id": "delegation_dependencies", - "description": "Create delegated work and maintain dependency edges.", - "operationIds": ["create_task", "set_dependencies"] + "description": "Create or reassign delegated work and maintain dependency edges.", + "operationIds": ["create_task", "reassign_task", "set_dependencies"] }, { "id": "governance", @@ -180,9 +180,9 @@ "legacyGroup": 6, "name": "Subtasks", "owner": "optional delegation tools", - "operationIds": ["create_task"], + "operationIds": ["create_task", "reassign_task"], "controlPlaneOperationIds": ["route_wake"], - "realSurface": "company issue create, child issue, assignment, and wake services", + "realSurface": "company issue create, child issue, guarded reassignment, and wake services", "mockStateDomains": ["company", "task", "actor", "blockers", "wake", "audit"], "prpEvidence": "company/task state diff, audit reference, and continuation wake evidence", "gap": "create_task is production-bound to ordinary active-issue child creation with assignment, dependency-ready wake, company checks, child limits, and durable source-scoped idempotency." diff --git a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md index 8e0670ab172..6e4a560953a 100644 --- a/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md +++ b/packages/paperclip-runner/spec/paperclip-agent-operation-groups.md @@ -6,7 +6,7 @@ Status: canonical explanatory contract for the Paperclip runner V1 surface. This document keeps three independent meanings of **group** separate. PRP families describe wire evidence and controller commands; capability placement decides who owns an operation; behavioral eval groups organize the 106 scenario corpus. None of the three axes can be used as a substitute for another. -The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **46 reconciled semantic operations** (14 always, 32 optional), and **106 scenarios in 16 behavior groups**. +The generated totals are **105 PRP events in 31 event families**, **18 controller commands in 7 command families**, **10 control-plane operations**, **47 reconciled semantic operations** (14 always, 33 optional), and **106 scenarios in 16 behavior groups**. ## Axis 1: PRP v1 event and command families @@ -87,7 +87,7 @@ Placement has exactly three outcomes: `answer_status_question`, `block_task`, `finish_task`, `get_task_context`, `get_task_history`, `inspect_operation_result`, `list_document_revisions`, `list_documents`, `read_document`, `register_deliverable`, `report_progress`, `request_human_input`, `request_review`, `write_document`. -### Optional operations (32) and grant groups (13) +### Optional operations (33) and grant groups (13) Grant groups are documentation/exposure bundles, not additional authority. The operation descriptor's exact `requiredClaims` remains decisive. @@ -95,7 +95,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o | --- | --- | --- | --- | | `discovery` | `search_tasks`
`list_agents`
`get_agent`
`list_projects`
`list_goals` | `discovery:agents:read`
`discovery:goals:read`
`discovery:projects:read`
`discovery:tasks:read` | Company-visible task, agent, project, and goal discovery. | | `projects` | `create_project`
`list_project_repositories` | none | Project creation and authorized repository discovery through the live company/run authority. | -| `delegation_dependencies` | `create_task`
`set_dependencies` | `delegation:tasks:create`
`dependencies:write` | Create delegated work and maintain dependency edges. | +| `delegation_dependencies` | `create_task`
`reassign_task`
`set_dependencies` | `delegation:tasks:assign`
`delegation:tasks:create`
`dependencies:write` | Create or reassign delegated work and maintain dependency edges. | | `governance` | `list_approvals`
`get_approval`
`get_approval_context`
`request_approval`
`decide_approval`
`comment_on_approval` | `governance:approvals:comment`
`governance:approvals:decide`
`governance:approvals:read`
`governance:approvals:request` | Read, request, comment on, and decide approvals under governed-action checks. | | `cases` | `list_cases`
`upsert_case` | `cases:read`
`cases:write` | Read and update case summaries without reusing issue-document authority. | | `workspace_runtime` | `get_workspace_runtime`
`control_workspace_service` | `workspace:control`
`workspace:read` | Inspect and control the active issue workspace runtime. | @@ -147,6 +147,7 @@ Grant groups are documentation/exposure bundles, not additional authority. The o | `manage_routine` | `optional_agent_tool` | `routines:write` | `standard`
`skill_test` | `admin` | `required` | no | `mock_extension:routines.manage` | `scenario`
`scenario_mock` | `unbound`
company admin/portability item event plus audit record
catalog PRP status: `audit_pending` | | `read_document` | `always_agent_tool` | none | `standard`
`ask`
`planning`
`skill_test` | `read` | `none` | no | `snapshot_read:active_task_document` | `scenario` + `live`
`live_codex` | `unbound`
read projection surfaced via a tool-result item event; no control-plane state diff
catalog PRP status: `audit_pending` | | `read_secret_value` | `optional_agent_tool` | `secrets:values:read` | `standard`
`skill_test` | `secret_read` | `none` | yes | `mock_extension:secrets.value` | `scenario`
`scenario_mock` | `unbound`
redacted tool-result item event; secret value never reaches the wire
catalog PRP status: `audit_pending` | +| `reassign_task` | `optional_agent_tool` | `delegation:tasks:assign` | `standard`
`skill_test` | `company_write` | `required` | no | `semantic_command:reassign_task` | `scenario` + `live`
`live_codex` | `PaperclipRunnerToolAuthority.reassign_task`
semantic-operation item event plus company-entity state diff and audit record
catalog PRP status: `bound` | | `register_deliverable` | `always_agent_tool` | none | `standard`
`planning`
`skill_test` | `task_write` | `required` | no | `semantic_command:register_deliverable` | `scenario` + `live`
`live_codex` | `unbound`
semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events
catalog PRP status: `audit_pending` | | `report_progress` | `always_agent_tool` | none | `standard`
`ask`
`planning`
`skill_test` | `task_write` | `required` | no | `semantic_command:report_progress` | `scenario` + `live`
`live_codex` | `unbound`
semantic-operation item event plus active-task state diff, work-assessment, and issue-status-decision events
catalog PRP status: `audit_pending` | | `request_approval` | `optional_agent_tool` | `governance:approvals:request` | `standard`
`skill_test` | `governance` | `required` | no | `semantic_command:request_approval` | `scenario` + `live`
`live_codex` | `unbound`
approval lifecycle plus governed-wait continuation and audit events
catalog PRP status: `audit_pending` | @@ -173,7 +174,7 @@ Behavior groups describe expected outcomes and trajectories. They do not grant t | [`st` — Status](#behavior-group-st-status) | always tools + control-plane arbitration | `answer_status_question`
`finish_task`
`block_task`
`request_review` | `reconcile_run`
`append_audit_record` | issue PATCH, review/liveness policy, and native finalization arbitration | `task`
`comments`
`interactions`
`blockers`
`audit`
`run` | 8 | semantic operation receipt, work assessment, issue-status decision, and terminal causality | Production semantic binding and additive typed operation/conflict receipts remain unimplemented. | | [`cm` — Comments](#behavior-group-cm-comments) | always tools | `get_task_history`
`report_progress` | `append_audit_record` | issue comment list/get/create routes | `task`
`comments`
`actor`
`idempotency`
`audit` | 6 | bounded read result or idempotent comment-write receipt plus audit reference | Active-task binding is unbound; cross-task comment mutation is deliberately outside V1. | | [`se` — Search](#behavior-group-se-search) | optional discovery tools | `search_tasks`
`list_agents`
`get_agent`
`list_projects`
`list_goals`
`list_project_repositories` | none | company issue search and agent/project/goal list/get routes | `company`
`task`
`actor`
`project`
`goal` | 4 | bounded redacted read projections through tool-result item events | Goal operations remain scenario-only. Project and repository discovery contracts are delivered by the live server authority; they are not implemented by the mock command dispatcher. | -| [`su` — Subtasks](#behavior-group-su-subtasks) | optional delegation tools | `create_task` | `route_wake` | company issue create, child issue, assignment, and wake services | `company`
`task`
`actor`
`blockers`
`wake`
`audit` | 4 | company/task state diff, audit reference, and continuation wake evidence | create_task is production-bound to ordinary active-issue child creation with assignment, dependency-ready wake, company checks, child limits, and durable source-scoped idempotency. | +| [`su` — Subtasks](#behavior-group-su-subtasks) | optional delegation tools | `create_task`
`reassign_task` | `route_wake` | company issue create, child issue, guarded reassignment, and wake services | `company`
`task`
`actor`
`blockers`
`wake`
`audit` | 4 | company/task state diff, audit reference, and continuation wake evidence | create_task is production-bound to ordinary active-issue child creation with assignment, dependency-ready wake, company checks, child limits, and durable source-scoped idempotency. | | [`bl` — Blockers](#behavior-group-bl-blockers) | always/optional tools + control plane | `block_task`
`set_dependencies` | `schedule_blocker_wake`
`route_wake` | issue relations, blocker projection, liveness validation, and blocker wake services | `task`
`blockers`
`wake`
`actor`
`audit`
`fault` | 5 | dependency diff, block receipt, attention routing, and issue-status decision | set_dependencies is production-bound for the active issue; block_task remains unbound, and cancelled-blocker receipts still need typed additive evidence. | | [`dp` — Documents and plans](#behavior-group-dp-documents-and-plans) | always tools; restore optional; destructive lifecycle control-plane-only | `list_documents`
`read_document`
`list_document_revisions`
`write_document` | `append_audit_record` | issue document list/read/upsert/revision/restore/lock/unlock/delete routes | `task`
`documents`
`interactions`
`idempotency`
`audit`
`fault` | 3 | bounded reads and revision-safe write/conflict/denial receipts with revision lineage | restore_document_revision is an approved optional-tool gap; lock/unlock/delete are intentionally control-plane-only. | | [`ix` — Interactions](#behavior-group-ix-interactions) | always tools + addressed resolver | `request_human_input` | `route_wake` | issue-thread interaction create/respond/accept/reject/withdraw services | `task`
`documents`
`interactions`
`wake`
`actor`
`idempotency` | 9 | interaction proposal/materialization/response/delivery and attention events | Production binding and semantic resolution receipts are unbound. | @@ -456,9 +457,9 @@ Current responsibility-based paths are normative. Numbered `phase-*` or mileston ### Catalog split and deliberate replacement -- Scenario/eval catalog: **38** operations. -- Live dispatcher catalog: **34** operations. -- Shared: **26**; union/canonical authority: **46**. +- Scenario/eval catalog: **39** operations. +- Live dispatcher catalog: **35** operations. +- Shared: **27**; union/canonical authority: **47**. - Scenario-only: `administer_company`, `export_company`, `inspect_operation_result`, `list_cases`, `list_company_skills`, `list_goals`, `list_routines`, `list_secret_metadata`, `manage_routine`, `read_secret_value`, `sync_company_skills`, `upsert_case`. - Live-only: `call_api`, `create_project`, `get_agent`, `get_approval`, `get_approval_context`, `list_project_repositories`, `schedule_wake`, `search_api`. - The generated provider contract contains exactly the live catalog; the canonical union remains the migration authority until all scenario-only operations are either implemented, deferred, or removed by an explicit reconciliation decision. diff --git a/packages/paperclip-runner/src/catalog/catalog-docs.test.ts b/packages/paperclip-runner/src/catalog/catalog-docs.test.ts index 56f2e1a0415..055503d1b7a 100644 --- a/packages/paperclip-runner/src/catalog/catalog-docs.test.ts +++ b/packages/paperclip-runner/src/catalog/catalog-docs.test.ts @@ -43,7 +43,7 @@ describe("capability-semantic-tools.md stays in sync with the catalog", () => { expect(Number(alwaysHeader)).toBe(always.length); expect(Number(optionalHeader)).toBe(optional.length); // Guard against the historical 36-vs-37 drift the reconciliation plan flagged. - expect(SCENARIO_CATALOG.length).toBe(38); + expect(SCENARIO_CATALOG.length).toBe(39); }); it("lists exactly the catalog's operation ids in the counts region", () => { diff --git a/packages/paperclip-runner/src/catalog/reconciliation.test.ts b/packages/paperclip-runner/src/catalog/reconciliation.test.ts index 12f46328e08..b339834422a 100644 --- a/packages/paperclip-runner/src/catalog/reconciliation.test.ts +++ b/packages/paperclip-runner/src/catalog/reconciliation.test.ts @@ -19,10 +19,10 @@ const packageRoot = resolve(dirname(fileURLToPath(import.meta.url)), "..", "..") describe("canonical semantic-catalog reconciliation authority", () => { it("pins the reconciled op-set relationship between the two catalogs", () => { const summary = capabilityCatalogReconciliation(); - expect(summary.scenarioCount).toBe(38); - expect(summary.liveCount).toBe(34); - expect(summary.sharedCount).toBe(26); - expect(summary.unionCount).toBe(46); + expect(summary.scenarioCount).toBe(39); + expect(summary.liveCount).toBe(35); + expect(summary.sharedCount).toBe(27); + expect(summary.unionCount).toBe(47); // Any operation added to or removed from either catalog without a // reconciliation decision changes these exact sets and fails the gate. expect(summary.liveOnly).toEqual([ @@ -52,7 +52,7 @@ describe("canonical semantic-catalog reconciliation authority", () => { }); it("is the single source both catalogs derive their operation set from", () => { - expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(46); + expect(CAPABILITY_CANONICAL_OPERATIONS).toHaveLength(47); const canonicalIds = new Set(CAPABILITY_CANONICAL_OPERATIONS.map((operation) => operation.operationId)); // Neither catalog may contain an operation absent from the canonical source. for (const tool of SCENARIO_CATALOG) expect(canonicalIds.has(tool.operationId)).toBe(true); @@ -86,7 +86,7 @@ describe("canonical semantic-catalog reconciliation authority", () => { }); it("names placement, claims, task modes, side-effect class, idempotency, redaction, mock mapping, real binding status, and PRP evidence for every operation", () => { - expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(46); + expect(CAPABILITY_CANONICAL_CATALOG).toHaveLength(47); for (const operation of CAPABILITY_CANONICAL_CATALOG) { expect(operation.placement).toMatch(/^(always|optional)_agent_tool$/); expect(Array.isArray(operation.requiredClaims)).toBe(true); @@ -112,7 +112,7 @@ describe("canonical semantic-catalog reconciliation authority", () => { it("classifies real binding status so generic_api_request is never product coverage", () => { const summary = capabilityCatalogReconciliation(); expect(summary.byRealBindingStatus).toEqual({ - live_codex: 33, + live_codex: 34, scenario_mock: 12, test_only: 1, }); diff --git a/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts index 40831bc0e85..f50e99e98e2 100644 --- a/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts +++ b/packages/paperclip-runner/src/catalog/semantic-action-catalog.test.ts @@ -69,7 +69,7 @@ describe("semantic action catalog", () => { (action) => action.operationId, ); - expect(operationIds).toHaveLength(33); + expect(operationIds).toHaveLength(34); expect(new Set(operationIds).size).toBe(operationIds.length); expect(operationIds).not.toContain("generic_api_request"); expect(Object.isFrozen(PAPERCLIP_SEMANTIC_ACTION_CATALOG)).toBe(true); diff --git a/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts b/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts index eeeb4e8842b..7f37c8d4427 100644 --- a/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts +++ b/packages/paperclip-runner/src/catalog/semantic-action-catalog.ts @@ -1,3 +1,4 @@ +import { reassignTaskAction } from "../protocol-actions/reassign-task.js"; import type { PaperclipJsonSchema, PaperclipSemanticActionDescriptor, @@ -414,6 +415,9 @@ const descriptors: readonly PaperclipSemanticActionDescriptor[] = [ ), outputSchema: operationReceipt, }), + descriptor({ + ...reassignTaskAction.live.descriptor, placement: "optional", effect: "write", + }), descriptor({ operationId: "set_dependencies", title: "Set task dependencies", diff --git a/packages/paperclip-runner/src/catalog/semantic-action-types.ts b/packages/paperclip-runner/src/catalog/semantic-action-types.ts index 7be978cb978..1dbf220cc81 100644 --- a/packages/paperclip-runner/src/catalog/semantic-action-types.ts +++ b/packages/paperclip-runner/src/catalog/semantic-action-types.ts @@ -22,6 +22,7 @@ export type PaperclipSemanticActionId = | "get_approval_context" | "get_workspace_runtime" | "control_workspace_service" + | "reassign_task" | "set_dependencies" | "create_skill" | "create_project" diff --git a/packages/paperclip-runner/src/issue-thread/live-projection.ts b/packages/paperclip-runner/src/issue-thread/live-projection.ts index a091295162d..06f5ceab78e 100644 --- a/packages/paperclip-runner/src/issue-thread/live-projection.ts +++ b/packages/paperclip-runner/src/issue-thread/live-projection.ts @@ -487,7 +487,7 @@ function derivedRecordItems( } } - if (operationId === "create_task" || operationId === "set_dependencies") { + if (operationId === "create_task" || operationId === "reassign_task" || operationId === "set_dependencies") { const created = refsOf("task") .filter((id) => id !== state.tasks[0]?.id) .map((ref) => state.tasks.find((candidate) => candidate.id === ref)) diff --git a/packages/paperclip-runner/src/mock-core/capability-control-plane-types.ts b/packages/paperclip-runner/src/mock-core/capability-control-plane-types.ts index 0fed1d27637..ea53c55b0c3 100644 --- a/packages/paperclip-runner/src/mock-core/capability-control-plane-types.ts +++ b/packages/paperclip-runner/src/mock-core/capability-control-plane-types.ts @@ -40,6 +40,7 @@ export interface CapabilityFixtureActor { } export interface CapabilityFixtureTask { + statusVersion?: number; id: string; companyId: string; identifier: string; @@ -391,6 +392,7 @@ export type CapabilitySemanticCommand = contentRef: string; title: string; }) + | (CapabilityBaseCommand & { kind: "reassign_task"; targetTaskId: string; assigneeActorId: string; expectedAssigneeActorId: string | null; expectedStatusVersion: number; reason: string }) | (CapabilityBaseCommand & { kind: "set_dependencies"; blockedByTaskIds: string[] }) | (CapabilityBaseCommand & { kind: "create_task"; @@ -482,6 +484,7 @@ export const CAPABILITY_COMMAND_REQUIRED_CLAIMS = { resolve_human_input: ["control_plane:interactions"], register_deliverable: [], set_dependencies: ["dependencies:write"], + reassign_task: ["delegation:tasks:assign"], create_task: ["delegation:tasks:create"], request_approval: ["governance:approvals:request"], decide_approval: ["governance:approvals:decide"], @@ -601,7 +604,7 @@ export function createCapabilityFixtureState(seed: CapabilityFixtureSeed = {}): activeRunId: null, company, actors: structuredClone(actors), - tasks: structuredClone(tasks), + tasks: structuredClone(tasks).map(task => ({ ...task, statusVersion: task.statusVersion ?? 0 })), outOfScopeTaskIds: structuredClone(seed.outOfScopeTaskIds ?? []), comments: structuredClone(seed.comments ?? []), documents: structuredClone(seed.documents ?? []), diff --git a/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.ts b/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.ts index 9801d5380be..984fdd11345 100644 --- a/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.ts +++ b/packages/paperclip-runner/src/mock-core/capability-mock-control-plane-adapter.ts @@ -891,6 +891,32 @@ export class CapabilityMockControlPlaneAdapter implements CapabilityMockControlP entityRefs.push(`artifact:${artifact.id}`, `work-product:${workProduct.id}`); break; } + case "reassign_task": { + const target = this.#task(command.targetTaskId); + const assignee = this.#actor(command.assigneeActorId); + this.#assertCompany(run.companyId, target.companyId, assignee.companyId); + if (target.id === task.id) throw new CapabilityMockControlPlaneError("reassignment_current_task", "Delegate with create_task instead of reassigning the caller"); + if (["done", "cancelled", "in_review"].includes(target.status)) throw new CapabilityMockControlPlaneError("reassignment_state_denied", "Task cannot be reassigned in this state"); + if (assignee.status !== "active") throw new CapabilityMockControlPlaneError("reassignment_target_unavailable", "Assignee must be active"); + if (target.assigneeActorId !== command.expectedAssigneeActorId || (target.statusVersion ?? 0) !== command.expectedStatusVersion) { + throw new CapabilityMockControlPlaneError("reassignment_conflict", "Refresh the task before deciding again"); + } + requireText(command.reason, "reassignment reason"); + entityRefs.push(`task:${target.id}`); + if (target.assigneeActorId === assignee.id) break; + if (target.executionRunId) { + const previous = this.#state.runs.find(candidate => candidate.id === target.executionRunId); + if (previous) previous.status = "cancelled"; + } + target.assigneeActorId = assignee.id; + target.statusVersion = (target.statusVersion ?? 0) + 1; + target.checkoutRunId = null; + target.executionRunId = null; + if (target.status === "in_progress") target.status = "todo"; + this.#appendComment(target.id, run.actorId, command.reason); + if (target.status === "todo") scheduledWakeIds.push(this.#scheduleWake(assignee.id, target.id, "manual", { reason: command.reason }, 0)); + break; + } case "set_dependencies": { this.#replaceDependencies(task, command.blockedByTaskIds); if (command.blockedByTaskIds.length > 0) { diff --git a/packages/paperclip-runner/src/mock-core/task-reassignment.test.ts b/packages/paperclip-runner/src/mock-core/task-reassignment.test.ts new file mode 100644 index 00000000000..1947e8b97f3 --- /dev/null +++ b/packages/paperclip-runner/src/mock-core/task-reassignment.test.ts @@ -0,0 +1,45 @@ +import { describe, expect, it } from "vitest"; +import { CapabilityMockControlPlaneAdapter } from "./capability-mock-control-plane-adapter.js"; + +async function fixture(status: "todo" | "blocked" | "done" = "todo", claims = ["delegation:tasks:assign"]) { + const base = new CapabilityMockControlPlaneAdapter().snapshot(); + const adapter = new CapabilityMockControlPlaneAdapter({ + actors: [...base.actors, { ...base.actors[0]!, id: "recipient", name: "Recipient" }], + tasks: [...base.tasks, { ...base.tasks[0]!, id: "target", identifier: "MCK-2", title: "Existing work", status, statusVersion: 0 }], + }); + await adapter.start(); + await adapter.openFixtureRun({ identity: { runId: "run-1", sessionId: "session-1", companyId: base.company.id, issueId: base.tasks[0]!.id, agentId: base.actors[0]!.id }, backendKind: "mock", sourceInstanceId: "reassignment-test", capabilities: claims }); + const call = { runId: "run-1", idempotencyKey: "handoff", command: { kind: "reassign_task" as const, targetTaskId: "target", assigneeActorId: "recipient", expectedAssigneeActorId: base.actors[0]!.id, expectedStatusVersion: 0, reason: "Recipient owns implementation" } }; + return { adapter, call, base }; +} + +describe("mock task reassignment conformance", () => { + it("changes one existing owner, schedules one wake, and deduplicates replay", async () => { + const { adapter, call } = await fixture(); + const result = await adapter.applyCommand(call); + await adapter.applyCommand(call); + const state = adapter.snapshot(); + expect(state.tasks).toHaveLength(2); + expect(state.tasks.find(task => task.id === "target")).toMatchObject({ assigneeActorId: "recipient", statusVersion: 1, status: "todo" }); + expect(state.wakes).toHaveLength(1); + expect(result.scheduledWakeIds).toEqual([state.wakes[0]!.id]); + expect(state.comments.filter(comment => comment.taskId === "target")).toHaveLength(1); + }); + it("preserves blocked state without a wake", async () => { + const { adapter, call } = await fixture("blocked"); + await adapter.applyCommand(call); + expect(adapter.snapshot().tasks.find(task => task.id === "target")).toMatchObject({ status: "blocked", assigneeActorId: "recipient" }); + expect(adapter.snapshot().wakes).toHaveLength(0); + }); + it.each(["claim", "owner", "version", "self", "terminal", "key-conflict"])("rejects %s without a second ownership effect", async kind => { + const { adapter, call, base } = await fixture(kind === "terminal" ? "done" : "todo", kind === "claim" ? [] : undefined); + if (kind === "owner") call.command.expectedAssigneeActorId = "someone-else"; + if (kind === "version") call.command.expectedStatusVersion = 99; + if (kind === "self") call.command.targetTaskId = base.tasks[0]!.id; + if (kind === "key-conflict") { await adapter.applyCommand(call); call.command.reason = "Conflicting request"; } + const before = adapter.snapshot(); + await expect(adapter.applyCommand(call)).rejects.toThrow(); + expect(adapter.snapshot().tasks).toEqual(before.tasks); + expect(adapter.snapshot().wakes).toEqual(before.wakes); + }); +}); diff --git a/packages/paperclip-runner/src/protocol-actions/index.ts b/packages/paperclip-runner/src/protocol-actions/index.ts index c71dc4a1fda..c762faae9c9 100644 --- a/packages/paperclip-runner/src/protocol-actions/index.ts +++ b/packages/paperclip-runner/src/protocol-actions/index.ts @@ -1,3 +1,4 @@ +import { reassignTaskAction } from "./reassign-task.js"; import { createSkillAction } from "./create-skill.js"; import { createProjectAction } from "./create-project.js"; import { listProjectRepositoriesAction } from "./list-project-repositories.js"; @@ -90,6 +91,7 @@ export const PAPERCLIP_PROTOCOL_ACTIONS = deepFreezeProtocolAction([ scheduleWakeAction, searchTasksAction, setDependenciesAction, + reassignTaskAction, syncCompanySkillsAction, upsertCaseAction, writeDocumentAction, diff --git a/packages/paperclip-runner/src/protocol-actions/reassign-task.ts b/packages/paperclip-runner/src/protocol-actions/reassign-task.ts new file mode 100644 index 00000000000..417e4fa44a7 --- /dev/null +++ b/packages/paperclip-runner/src/protocol-actions/reassign-task.ts @@ -0,0 +1,287 @@ +/** Canonical definition and documentation for `reassign_task`. */ +export const reassignTaskAction = { + "id": "reassign_task", + "canonical": { + "operationId": "reassign_task", + "surfaces": [ + "scenario", + "live" + ], + "placement": "optional_agent_tool", + "optionalGroup": "delegation_dependencies", + "requiredClaims": [ + "delegation:tasks:assign" + ], + "taskModes": [ + "standard", + "skill_test" + ], + "sideEffectClass": "company_write", + "idempotency": "required", + "disabledByDefault": false, + "realBindingStatus": "live_codex", + "realServiceBinding": "PaperclipRunnerToolAuthority.reassign_task", + "prpEvidence": "semantic-operation item event plus company-entity state diff and audit record", + "prpBindingStatus": "bound", + "legacyAliases": [] + }, + "documentation": { + "title": "Reassign task", + "description": "Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run\u2019s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.", + "note": null + }, + "examples": { + "call": { + "operationId": "reassign_task", + "input": { + "idempotencyKey": "reassign-example", + "taskId": "task-delegated", + "assigneeActorId": "actor-manager", + "expectedAssigneeActorId": "actor-engineer", + "expectedStatusVersion": 0, + "reason": "The manager owns the next step." + } + }, + "scenarioCall": { + "operationId": "reassign_task", + "idempotencyKey": "reassign-example", + "input": { + "taskId": "task-delegated", + "assigneeActorId": "actor-manager", + "expectedAssigneeActorId": "actor-engineer", + "expectedStatusVersion": 0, + "reason": "The manager owns the next step." + } + }, + "success": { + "ok": true, + "operationId": "reassign_task", + "result": { + "commandId": "reassign-example", + "disposition": "applied", + "stateRevision": 1, + "entityRefs": [ + "task-delegated" + ], + "scheduledWakeIds": [ + "wake-example" + ] + } + } + }, + "live": { + "order": 46, + "descriptor": { + "schema": "paperclip.semantic-tool.v1", + "operationId": "reassign_task", + "version": 1, + "title": "Reassign task", + "description": "Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run\u2019s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.", + "exposure": "optional", + "requiredClaims": [ + "delegation:tasks:assign" + ], + "allowedModes": [ + "standard", + "skill_test" + ], + "inputSchema": { + "type": "object", + "properties": { + "idempotencyKey": { + "type": "string", + "minLength": 1, + "maxLength": 240, + "description": "Caller-stable retry key." + }, + "taskId": { + "type": "string", + "minLength": 1, + "description": "Existing task ID from search_tasks." + }, + "assigneeActorId": { + "type": "string", + "minLength": 1, + "description": "New company agent ID from list_agents." + }, + "expectedAssigneeActorId": { + "type": [ + "string", + "null" + ], + "description": "Current assigneeAgentId from search_tasks; null means unassigned." + }, + "expectedStatusVersion": { + "type": "integer", + "minimum": 0, + "description": "Current statusVersion from search_tasks." + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 20000, + "description": "Why the task should move and context for the new owner." + } + }, + "required": [ + "idempotencyKey", + "taskId", + "assigneeActorId", + "expectedAssigneeActorId", + "expectedStatusVersion", + "reason" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": { + "commandId": { + "type": "string", + "description": "Stable command identifier.", + "minLength": 1, + "maxLength": 200 + }, + "disposition": { + "enum": [ + "applied", + "duplicate" + ] + }, + "stateRevision": { + "type": "integer", + "minimum": 0 + }, + "entityRefs": { + "type": "array", + "description": "Entities affected by the operation.", + "items": { + "type": "string", + "minLength": 1 + }, + "maxItems": 200, + "uniqueItems": true + }, + "scheduledWakeIds": { + "type": "array", + "description": "Wake identifiers scheduled by the operation.", + "items": { + "type": "string", + "minLength": 1 + }, + "maxItems": 200, + "uniqueItems": true + } + }, + "required": [ + "commandId", + "disposition", + "stateRevision", + "entityRefs", + "scheduledWakeIds" + ], + "additionalProperties": false + } + } + }, + "scenario": { + "order": 40, + "descriptor": { + "operationId": "reassign_task", + "version": 1, + "title": "Reassign task", + "description": "Reassign an existing task to another company agent. Read search_tasks first and supply its current assignee and statusVersion to prevent overwriting a concurrent change. Preserve the task, documents, dependencies, and blocked/backlog status. Active work is stopped before handoff. Use a stable idempotency key for retries. Cannot reassign this run\u2019s own task, conversations, completed tasks, or pending reviews; use create_task for delegation from the current task.", + "inputSchema": { + "type": "object", + "properties": { + "taskId": { + "type": "string", + "minLength": 1, + "description": "Existing task ID from search_tasks." + }, + "assigneeActorId": { + "type": "string", + "minLength": 1, + "description": "New company agent ID from list_agents." + }, + "expectedAssigneeActorId": { + "type": [ + "string", + "null" + ], + "description": "Current assigneeAgentId from search_tasks; null means unassigned." + }, + "expectedStatusVersion": { + "type": "integer", + "minimum": 0, + "description": "Current statusVersion from search_tasks." + }, + "reason": { + "type": "string", + "minLength": 1, + "maxLength": 20000, + "description": "Why the task should move and context for the new owner." + } + }, + "required": [ + "taskId", + "assigneeActorId", + "expectedAssigneeActorId", + "expectedStatusVersion", + "reason" + ], + "additionalProperties": false + }, + "outputSchema": { + "type": "object", + "properties": { + "schema": { + "type": "string", + "enum": [ + "paperclip.capability.tool-result.v1" + ] + }, + "ok": { + "type": "boolean" + }, + "operationId": { + "const": "reassign_task" + }, + "operationResultId": { + "type": "string", + "minLength": 1 + }, + "value": {}, + "commandResult": {}, + "authorization": {} + }, + "required": [ + "schema", + "ok", + "operationId", + "operationResultId", + "value", + "commandResult", + "authorization" + ], + "additionalProperties": false + }, + "disposition": "optional_agent_tool", + "optionalGroup": "delegation_dependencies", + "requiredClaims": [ + "delegation:tasks:assign" + ], + "taskModes": [ + "standard", + "skill_test" + ], + "sideEffectClass": "company_write", + "idempotency": "required", + "redaction": [], + "mockCommandMapping": { + "kind": "semantic_command", + "commandKind": "reassign_task" + } + } + } +} as const; diff --git a/packages/paperclip-runner/src/semantic-tools/discovery.ts b/packages/paperclip-runner/src/semantic-tools/discovery.ts index 28bbcad11b4..d1bf2bd4335 100644 --- a/packages/paperclip-runner/src/semantic-tools/discovery.ts +++ b/packages/paperclip-runner/src/semantic-tools/discovery.ts @@ -28,7 +28,7 @@ const NAMESPACE: Readonly> = Objec request_human_input: "documents", register_deliverable: "documents", finish_task: "active_task", block_task: "active_task", request_review: "active_task", search_tasks: "discovery", list_agents: "discovery", get_agent: "discovery", create_skill: "skills", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects", - set_dependencies: "delegation", list_approvals: "governance", get_approval: "governance", + set_dependencies: "delegation", reassign_task: "delegation", list_approvals: "governance", get_approval: "governance", get_approval_context: "governance", request_approval: "governance", decide_approval: "governance", comment_on_approval: "governance", get_workspace_runtime: "workspace", control_workspace_service: "workspace", diff --git a/packages/paperclip-runner/src/semantic-tools/dispatcher.ts b/packages/paperclip-runner/src/semantic-tools/dispatcher.ts index 94068d72301..eeba423b6f9 100644 --- a/packages/paperclip-runner/src/semantic-tools/dispatcher.ts +++ b/packages/paperclip-runner/src/semantic-tools/dispatcher.ts @@ -267,7 +267,7 @@ export class CapabilitySemanticDispatcher { (query === undefined || `${task.identifier} ${task.title} ${task.description ?? ""}`.toLowerCase().includes(query)) && (statuses.length === 0 || statuses.includes(task.status)), ).slice(0, limit); - return readSuccess(state.revision, { tasks }); + return readSuccess(state.revision, { tasks: tasks.map(task => ({ ...task, statusVersion: task.statusVersion ?? 0 })) }); } case "list_approvals": return readSuccess(state.revision, { approvals: state.approvals }); @@ -375,6 +375,9 @@ export class CapabilitySemanticDispatcher { case "request_review": command = { kind: "request_review", taskId, summary: requiredString(input.summary) }; break; + case "reassign_task": + command = { kind: "reassign_task", taskId, targetTaskId: requiredString(input.taskId), assigneeActorId: requiredString(input.assigneeActorId), expectedAssigneeActorId: input.expectedAssigneeActorId === null ? null : requiredString(input.expectedAssigneeActorId), expectedStatusVersion: Number(input.expectedStatusVersion), reason: requiredString(input.reason) }; + break; case "set_dependencies": command = { kind: "set_dependencies", taskId, blockedByTaskIds: optionalStringArray(input.blockedByTaskIds) }; break; diff --git a/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts b/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts index 6f255660c68..bbc0c523025 100644 --- a/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts +++ b/packages/paperclip-runner/src/semantic-tools/paperclip-discovery.ts @@ -35,7 +35,7 @@ const NAMESPACE: Readonly> = get_approval_context: "governance", get_workspace_runtime: "workspace", control_workspace_service: "workspace", - set_dependencies: "delegation", + set_dependencies: "delegation", reassign_task: "delegation", create_skill: "skills", create_task: "delegation", create_project: "projects", list_project_repositories: "projects", list_projects: "projects", request_approval: "governance", decide_approval: "governance", diff --git a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts index e02fcdccff1..da4afd4f882 100644 --- a/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts +++ b/packages/paperclip-runner/src/semantic-tools/semantic-tools.test.ts @@ -94,7 +94,7 @@ describe("Capability semantic catalog and authorization", () => { it("publishes a stable narrow catalog without credentials or control-plane-owned tools", () => { const names = CAPABILITY_SEMANTIC_TOOL_CATALOG.map((tool) => tool.operationId); expect(new Set(names).size).toBe(names.length); - expect(names).toHaveLength(34); + expect(names).toHaveLength(35); expect(names).toContain("get_task_context"); expect(names).toContain("finish_task"); expect(names).not.toContain("checkout_task"); diff --git a/packages/paperclip-runner/src/semantic-tools/types.ts b/packages/paperclip-runner/src/semantic-tools/types.ts index 68ac9449a87..ea9339edf8d 100644 --- a/packages/paperclip-runner/src/semantic-tools/types.ts +++ b/packages/paperclip-runner/src/semantic-tools/types.ts @@ -39,6 +39,7 @@ export type CapabilitySemanticOperationId = | "get_approval_context" | "get_workspace_runtime" | "control_workspace_service" + | "reassign_task" | "set_dependencies" | "create_skill" | "create_project" diff --git a/packages/paperclip-runner/src/tools/capability-semantic-tool-runtime.ts b/packages/paperclip-runner/src/tools/capability-semantic-tool-runtime.ts index fb1eab16bb7..060131c33f7 100644 --- a/packages/paperclip-runner/src/tools/capability-semantic-tool-runtime.ts +++ b/packages/paperclip-runner/src/tools/capability-semantic-tool-runtime.ts @@ -1583,6 +1583,8 @@ function commandForOperation( priority: typeof input.priority === "string" ? input.priority as never : undefined, blockedByTaskIds: optionalStringArray(input.blockedByTaskIds), }; + case "reassign_task": + return { kind: "reassign_task", targetTaskId: requireString(input.taskId), assigneeActorId: requireString(input.assigneeActorId), expectedAssigneeActorId: input.expectedAssigneeActorId === null ? null : requireString(input.expectedAssigneeActorId), expectedStatusVersion: Number(input.expectedStatusVersion), reason: requireString(input.reason) }; case "set_dependencies": return { kind: "set_dependencies", blockedByTaskIds: optionalStringArray(input.blockedByTaskIds) ?? [] }; case "request_approval": diff --git a/server/src/services/heartbeat.ts b/server/src/services/heartbeat.ts index 6fc77e2423e..57db2850dcb 100644 --- a/server/src/services/heartbeat.ts +++ b/server/src/services/heartbeat.ts @@ -3649,6 +3649,7 @@ interface WakeupOptions { issueStateGuard?: { statuses: string[]; assigneeAgentId: string; + statusVersion?: number; }; /** Keep causally distinct external chat continuations out of an existing run. */ allowRunCoalescing?: boolean; @@ -24072,6 +24073,21 @@ export function heartbeatService( runnerRemoteProviderPackPath: runtimeEnv.PAPERCLIP_RUNNER_REMOTE_PROVIDER_PACK_PATH?.trim() || null, + stopTaskForReassignment: async (target) => { + await settleLiveRunnerGoalBeforeInterrupt(db, target); + if (!target.runId) return; + const prior = await getRun(target.runId); + if (!prior || prior.companyId !== target.companyId || prior.agentId !== target.agentId) { + throw conflict("Reassignment run binding changed"); + } + const stopped = await cancelRunInternal(target.runId, "Cancelled for task reassignment", { + errorCode: "issue_reassigned", suppressImmediateRecovery: true, + resultJson: { reassignmentStopConfirmed: true }, + }); + if (stopped && ["running", "queued", "scheduled_retry"].includes(stopped.status)) { + throw conflict("The previous run did not stop; reassignment was not applied"); + } + }, enqueueWakeup, onSpawn: async (meta) => { markDispatchStarted(); @@ -26717,6 +26733,7 @@ export function heartbeatService( conversationUserId: issues.conversationUserId, conversationState: issues.conversationState, status: issues.status, + statusVersion: issues.statusVersion, projectId: issues.projectId, projectWorkspaceId: issues.projectWorkspaceId, executionWorkspaceId: issues.executionWorkspaceId, @@ -26942,7 +26959,8 @@ export function heartbeatService( if ( issueStateGuard && (!issueStateGuard.statuses.includes(issue.status) || - issue.assigneeAgentId !== issueStateGuard.assigneeAgentId) + issue.assigneeAgentId !== issueStateGuard.assigneeAgentId || + (issueStateGuard.statusVersion !== undefined && issue.statusVersion !== issueStateGuard.statusVersion)) ) { await tx.insert(agentWakeupRequests).values({ ...durableReceiptFields, diff --git a/server/src/services/issues.ts b/server/src/services/issues.ts index b3d297bdf44..f2969a029d3 100644 --- a/server/src/services/issues.ts +++ b/server/src/services/issues.ts @@ -10819,6 +10819,12 @@ export function issueService(db: Db) { projectGoalId: nextProjectGoalId, defaultGoalId: defaultCompanyGoal?.id ?? null, }); + // Ownership changes invalidate observed handoff versions even if status + // stays the same, including an A -> B -> A assignment race. + if ((issueData.assigneeAgentId !== undefined && issueData.assigneeAgentId !== receiptExisting.assigneeAgentId) + || (issueData.assigneeUserId !== undefined && issueData.assigneeUserId !== receiptExisting.assigneeUserId)) { + patch.statusVersion = sql`${issues.statusVersion} + 1` as unknown as number; + } // Reasserting Blocked or changing its blockers is a fresh decision even // when the status string stays the same. Invalidate recovery's prior // status receipt without treating comment recency as blocking intent. diff --git a/server/src/services/native-runtime/native-session-executor.ts b/server/src/services/native-runtime/native-session-executor.ts index 549ea9f4022..9e5347b566e 100644 --- a/server/src/services/native-runtime/native-session-executor.ts +++ b/server/src/services/native-runtime/native-session-executor.ts @@ -6889,6 +6889,7 @@ export async function executePaperclipNativeSession(input: { runnerRemoteCodexPath?: string | null; runnerRemoteCodexNpmSpec?: string | null; runnerRemoteProviderPackPath?: string | null; + stopTaskForReassignment?: (target: { companyId: string; issueId: string; agentId: string; runId: string | null }) => Promise; enqueueWakeup?: ( agentId: string, options: { @@ -9933,6 +9934,7 @@ export async function createRunnerdBackend(input: { runnerRemoteProviderPackPath?: string | null; trace?: NativeRunTrace; onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise; + stopTaskForReassignment?: (target: { companyId: string; issueId: string; agentId: string; runId: string | null }) => Promise; enqueueWakeup?: ( agentId: string, options: { @@ -10048,6 +10050,7 @@ async function createRunnerdBackendWithinSessionClaim( : undefined, currentWakeComments: currentWakeComments ?? undefined, chatAttachmentReadScope: input.chatAttachmentReadScope, + stopTaskForReassignment: input.stopTaskForReassignment, enqueueWakeup: input.enqueueWakeup, }); const authorityEpoch = new SessionToolAuthorityEpoch( diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts index eaab6bd5d52..3ea436f3bba 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.test.ts @@ -90,7 +90,7 @@ describe("PaperclipRunnerToolAuthority", () => { issueId, runId, }); - expect(authority.definitions()).toHaveLength(26); + expect(authority.definitions()).toHaveLength(27); const questions = authority.definitions().find(tool => tool.name === "request_human_input")!; expect(questions.description).toContain("ask only the next unanswered question"); expect(questions.description).toContain("Never infer answers"); diff --git a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts index 5912b3b2358..e9862d0d540 100644 --- a/server/src/services/native-runtime/paperclip-runner-tool-authority.ts +++ b/server/src/services/native-runtime/paperclip-runner-tool-authority.ts @@ -1,3 +1,5 @@ +import { assertAssignableAgent } from "../agent-assignability.js"; +import { authorizationService } from "../authorization.js"; import { handoffPlanContext } from "./handoff-plan-context.js"; import { callCreateSkillTool } from "../skill-tools.js"; import { callProjectTool } from "../project-tools.js"; @@ -23,12 +25,14 @@ import { workspaceFileResourceService } from "../workspace-file-resources.js"; import { badRequest, forbidden } from "../../errors.js"; import { searchRunnerApi } from "./runner-api-catalog.js"; import { executeRunnerApi, validateRunnerApiCall, RUNNER_API_MAX_BYTES, type RunnerApiFile } from "./runner-api-client.js"; -import { and, desc, eq, isNull, notInArray } from "drizzle-orm"; +import { and, desc, eq, isNull, notInArray, sql } from "drizzle-orm"; import type { Db } from "@paperclipai/db"; import { + activityLog, agents, agentWakeupRequests, chatEndpoints, + chatConversations, documentRevisions, heartbeatRuns, issueApprovals, @@ -76,7 +80,7 @@ const IMPLEMENTED_OPERATIONS = new Set([ "search_api", "call_api", "get_task_context", "get_task_history", "search_tasks", "report_progress", "request_human_input", - "create_skill", "create_task", "set_dependencies", "create_project", "list_project_repositories", "list_projects", "register_deliverable", + "create_skill", "create_task", "reassign_task", "set_dependencies", "create_project", "list_project_repositories", "list_projects", "register_deliverable", "list_documents", "read_document", "list_document_revisions", "write_document", "list_agents", "get_agent", "list_approvals", "get_approval", "get_approval_context", ]); @@ -106,6 +110,7 @@ type Binding = { readRemoteWorkspaceFile?: RemoteWorkspaceFileReader; currentWakeComments?: CurrentWakeCommentsBinding; chatAttachmentReadScope?: NativeChatAttachmentReadScope; + stopTaskForReassignment?: (target: { companyId: string; issueId: string; agentId: string; runId: string | null }) => Promise; enqueueWakeup?: (agentId: string, options: { source: "assignment"; triggerDetail: "system"; @@ -115,7 +120,7 @@ type Binding = { requestedByActorType: "agent"; requestedByActorId: string; contextSnapshot: Record; - issueStateGuard?: { statuses: string[]; assigneeAgentId: string }; + issueStateGuard?: { statuses: string[]; assigneeAgentId: string; statusVersion?: number }; }) => Promise; }; @@ -467,6 +472,7 @@ export class PaperclipRunnerToolAuthority { (await captureRunIdentity(this.db, this.binding)).context?.id ?? null); case "create_task": return this.#createTask(input, (await captureRunIdentity(this.db, this.binding)).context?.id ?? null); + case "reassign_task": return this.#reassignTask(input); case "set_dependencies": return this.#setDependencies(input); case "register_deliverable": return this.#registerDeliverable(input); default: throw new Error("paperclip_runner_tool_not_bound"); @@ -909,6 +915,131 @@ export class PaperclipRunnerToolAuthority { return result; } + + async #reassignTask(input: Record): Promise { + const key = requiredString(input.idempotencyKey); + const taskId = requiredUuid(input.taskId); + const assigneeAgentId = requiredUuid(input.assigneeActorId); + const expectedOwner = input.expectedAssigneeActorId === null ? null : requiredUuid(input.expectedAssigneeActorId); + const expectedVersion = input.expectedStatusVersion; + const reason = requiredString(input.reason); + if (!Number.isSafeInteger(expectedVersion) || Number(expectedVersion) < 0 || key.length > 240 || reason.length > 20_000) { + throw new Error("paperclip_runner_tool_input_invalid"); + } + if (taskId === this.binding.issueId) { + throw new Error("paperclip_runner_reassignment_current_task: delegate with create_task; this tool cannot interrupt its own caller"); + } + const durableKey = `reassign:${this.binding.issueId}:${key}`; + const fingerprint = createHash("sha256").update(canonicalJson(input)).digest("hex"); + const priorReceipt = async (tx: Db): Promise | null> => { + const [prior] = await tx.select({ details: activityLog.details }).from(activityLog).where(and( + eq(activityLog.companyId, this.binding.companyId), eq(activityLog.action, "issue.reassigned"), + sql`${activityLog.details}->>'reassignmentKey' = ${durableKey}`, + )).limit(1); + if (!prior) return null; + if (prior.details?.inputFingerprint !== fingerprint) throw new Error("paperclip_runner_tool_idempotency_conflict"); + return record(prior.details?.receipt); + }; + const authorize = async (tx: Db, run: typeof heartbeatRuns.$inferSelect) => { + const [target] = await tx.select().from(issues).where(and(eq(issues.id, taskId), eq(issues.companyId, this.binding.companyId))).for("update"); + if (!target) throw new Error("paperclip_runner_task_not_found"); + const actor = { type: "agent" as const, source: "agent_jwt" as const, companyId: this.binding.companyId, + agentId: this.binding.agentId, runId: this.binding.runId, onBehalfOfUserId: run.responsibleUserId }; + for (const action of ["issue:read", "tasks:assign"] as const) { + const decision = await authorizationService(tx).decide({ actor, action, resource: { + type: "issue", companyId: this.binding.companyId, issueId: taskId, projectId: target.projectId, + parentIssueId: target.parentId, assigneeAgentId: action === "tasks:assign" ? assigneeAgentId : target.assigneeAgentId, + assigneeUserId: action === "tasks:assign" ? null : target.assigneeUserId, status: target.status, + } }); + if (!decision.allowed) throw forbidden(decision.explanation); + } + await assertAssignableAgent(tx, this.binding.companyId, assigneeAgentId, { kind: "work" }); + const [externalChat] = await tx.select({ id: chatConversations.id }).from(chatConversations).where(and( + eq(chatConversations.companyId, this.binding.companyId), eq(chatConversations.issueId, taskId), + )).limit(1); + if (externalChat) throw new Error("paperclip_runner_reassignment_conversation_locked"); + return target; + }; + const validate = (target: typeof issues.$inferSelect) => { + if (target.conversationAgentId) throw new Error("paperclip_runner_reassignment_conversation_locked"); + if (["done", "cancelled", "in_review"].includes(target.status) || record(target.executionState).status === "pending") { + throw new Error("paperclip_runner_reassignment_state_denied"); + } + if (target.assigneeUserId || target.assigneeAgentId !== expectedOwner || target.statusVersion !== expectedVersion) { + throw new Error("paperclip_runner_reassignment_conflict: refresh search_tasks before deciding again"); + } + }; + // Stop outside row locks: the runtime needs those locks to acknowledge cancellation. + // A second locked check below prevents any intervening ownership/version change. + const prepared = await this.db.transaction(async (tx) => { + const context = await this.#lockAuthorizedMutationContext(tx as unknown as Db); + if (context.issue.workMode !== "standard") throw new Error("paperclip_runner_tool_mode_denied"); + const target = await authorize(tx as unknown as Db, context.run); + if (await priorReceipt(tx as unknown as Db)) return null; + validate(target); + return target; + }); + if (prepared && prepared.assigneeAgentId && prepared.assigneeAgentId !== assigneeAgentId) { + if (!this.binding.stopTaskForReassignment && prepared.executionRunId) { + throw new Error("paperclip_runner_reassignment_stop_unavailable"); + } + await this.binding.stopTaskForReassignment?.({ companyId: this.binding.companyId, issueId: taskId, + agentId: prepared.assigneeAgentId, runId: prepared.executionRunId }); + } + let publication: Awaited>["publication"] | null = null; + const result = await this.#withMutationReceipt("reassign_task", key, input, async (tx, context) => { + if (context.issue.workMode !== "standard") throw new Error("paperclip_runner_tool_mode_denied"); + const target = await authorize(tx, context.run); + const prior = await priorReceipt(tx); + if (prior) return prior; + validate(target); + if (target.executionRunId && target.executionRunId !== prepared?.executionRunId) { + throw new Error("paperclip_runner_reassignment_conflict: a new execution started; refresh before retrying"); + } + if (target.executionRunId && target.assigneeAgentId !== assigneeAgentId) { + const [run] = await tx.select({ status: heartbeatRuns.status }).from(heartbeatRuns).where(eq(heartbeatRuns.id, target.executionRunId)); + if (run && ["running", "queued", "scheduled_retry"].includes(run.status)) throw new Error("paperclip_runner_reassignment_stop_unconfirmed"); + } + const changed = target.assigneeAgentId !== assigneeAgentId; + const updated = changed ? await issueService(tx).update(taskId, { + companyGuard: this.binding.companyId, assigneeAgentId, assigneeUserId: null, + status: target.status === "in_progress" ? "todo" : target.status, + statusVersion: target.statusVersion + 1, actorAgentId: this.binding.agentId, + }, tx) : target; + if (!updated) throw new Error("paperclip_runner_task_not_found"); + const receipt = { + commandId: `reassign-task:${taskId}:${updated.statusVersion}`, disposition: changed ? "applied" : "duplicate", + stateRevision: updated.statusVersion, entityRefs: [taskId], + scheduledWakeIds: changed && updated.status === "todo" ? [`${durableKey}:${updated.statusVersion}`] : [], + }; + if (changed) await issueService(tx).addComment(taskId, reason, { agentId: this.binding.agentId, runId: this.binding.runId }); + const activity = await persistActivity(tx, { + companyId: this.binding.companyId, actorType: "agent", actorId: this.binding.agentId, + agentId: this.binding.agentId, runId: this.binding.runId, issueId: taskId, + action: "issue.reassigned", entityType: "issue", entityId: taskId, + details: { source: "paperclip_runner_protocol", reassignmentKey: durableKey, inputFingerprint: fingerprint, + previousAssigneeAgentId: target.assigneeAgentId, assigneeAgentId, reason, changed, receipt }, + }); + publication = activity.publication; + return receipt; + }, { beforeReceiptReplay: async (tx, context) => { await authorize(tx, context.run); } }) as { + stateRevision: number; scheduledWakeIds: string[]; + }; + if (publication) publishActivity(publication); + // Replay repairs a failed dispatch. The durable wake key and state/version guard + // prevent duplicate successors and prevent waking a later, unrelated assignment. + if (result.scheduledWakeIds.length && this.binding.enqueueWakeup) { + await this.binding.enqueueWakeup(assigneeAgentId, { + source: "assignment", triggerDetail: "system", reason: "issue_assigned", + payload: { issueId: taskId, mutation: "reassign_task", reason }, + idempotencyKey: result.scheduledWakeIds[0]!, requestedByActorType: "agent", requestedByActorId: this.binding.agentId, + contextSnapshot: { issueId: taskId, source: "paperclip_runner.reassign_task", reassignmentReason: reason }, + issueStateGuard: { statuses: ["todo"], assigneeAgentId, statusVersion: result.stateRevision }, + }); + } + return result; + } + async #setDependencies(input: Record): Promise { const idempotencyKey = requiredString(input.idempotencyKey); if (!Array.isArray(input.blockedByTaskIds)) { diff --git a/server/src/services/native-runtime/runner-api-catalog.ts b/server/src/services/native-runtime/runner-api-catalog.ts index fbdbd73b07a..90c1a37b1ab 100644 --- a/server/src/services/native-runtime/runner-api-catalog.ts +++ b/server/src/services/native-runtime/runner-api-catalog.ts @@ -48,7 +48,7 @@ function dedicatedTools(method: string, path: string): string[] { if (/\/issues\/\{[^}]+\}\/comments$/.test(path)) return method === "GET" ? ["get_task_history"] : ["report_progress"]; if (/\/issues\/\{[^}]+\}\/documents/.test(path)) return method === "DELETE" ? [] : method === "GET" ? ["list_documents", "read_document", "list_document_revisions"] : ["write_document"]; if (/\/issues$/.test(path)) return method === "GET" ? ["search_tasks"] : ["create_task"]; - if (/\/issues\/\{[^}]+\}$/.test(path)) return method === "GET" ? ["get_task_context"] : ["set_dependencies", "finish_task", "block_task", "request_review"]; + if (/\/issues\/\{[^}]+\}$/.test(path)) return method === "GET" ? ["get_task_context"] : ["reassign_task", "set_dependencies", "finish_task", "block_task", "request_review"]; if (/\/agents$/.test(path) && method === "GET") return ["list_agents"]; if (/\/agents\/(me|\{[^}]+\})$/.test(path) && method === "GET") return ["get_agent"]; if (/\/approvals$/.test(path) && method === "GET") return ["list_approvals"]; diff --git a/server/src/services/native-runtime/task-reassignment.test.ts b/server/src/services/native-runtime/task-reassignment.test.ts new file mode 100644 index 00000000000..1c9cb35f30e --- /dev/null +++ b/server/src/services/native-runtime/task-reassignment.test.ts @@ -0,0 +1,165 @@ +import { randomUUID } from "node:crypto"; +import { afterAll, beforeAll, describe, expect, it, vi } from "vitest"; +import { eq, and } from "drizzle-orm"; +import { activityLog, agents, companies, createDb, heartbeatRuns, issues, issueComments } from "@paperclipai/db"; +import { startEmbeddedPostgresTestDatabase } from "../../__tests__/helpers/embedded-postgres.js"; +import { issueService } from "../issues.js"; +import { PaperclipRunnerToolAuthority } from "./paperclip-runner-tool-authority.js"; + +describe("runner task reassignment", () => { + let temporary: Awaited>; + let db: ReturnType; + beforeAll(async () => { + temporary = await startEmbeddedPostgresTestDatabase("runner-reassign-"); + db = createDb(temporary.connectionString); + }); + afterAll(async () => { await temporary?.cleanup(); }); + + async function fixture(status = "todo") { + const companyId = randomUUID(), agentId = randomUUID(), nextId = randomUUID(), issueId = randomUUID(), targetId = randomUUID(), runId = randomUUID(); + await db.insert(companies).values({ id: companyId, name: "Reassignment", issuePrefix: `R${companyId.slice(0, 6)}` }); + await db.insert(agents).values([ + { id: agentId, companyId, name: "Manager", role: "ceo", status: "active", adapterType: "paperclip_runner" }, + { id: nextId, companyId, name: "Engineer", role: "engineer", status: "active", adapterType: "paperclip_runner" }, + ]); + await db.insert(issues).values([ + { id: issueId, companyId, title: "Coordinate", status: "in_progress", assigneeAgentId: agentId }, + { id: targetId, companyId, title: "Existing work", status, assigneeAgentId: agentId, description: "Keep the existing plan and context" }, + ]); + await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", runtimeMode: "native", nativeIssueId: issueId, invocationSource: "assignment", triggerDetail: "system", contextSnapshot: { issueId } }); + await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, issueId)); + const enqueueWakeup = vi.fn().mockResolvedValue({ id: randomUUID() }); + const stopTaskForReassignment = vi.fn().mockResolvedValue(undefined); + const binding = { companyId, agentId, issueId, runId, enqueueWakeup, stopTaskForReassignment }; + const authority = new PaperclipRunnerToolAuthority(db, binding); + const call = { tool: "reassign_task", callId: "handoff", arguments: { taskId: targetId, assigneeActorId: nextId, expectedAssigneeActorId: agentId, expectedStatusVersion: 0, reason: "Engineer owns implementation; retain the existing plan.", idempotencyKey: "handoff" } }; + const target = async () => (await db.select().from(issues).where(eq(issues.id, targetId)))[0]!; + return { companyId, agentId, nextId, issueId, targetId, runId, authority, binding, call, target, enqueueWakeup, stopTaskForReassignment }; + } + + it("moves existing work once, persists context and audit, and dispatches a guarded wake", async () => { + const f = await fixture(); + const receipt = await f.authority.execute(f.call); + expect(await f.target()).toMatchObject({ assigneeAgentId: f.nextId, status: "todo", statusVersion: 1, description: "Keep the existing plan and context" }); + await expect(f.authority.execute({ ...f.call, callId: "retry" })).resolves.toEqual(receipt); + expect(f.stopTaskForReassignment).toHaveBeenCalledTimes(1); + expect(f.enqueueWakeup).toHaveBeenCalledWith(f.nextId, expect.objectContaining({ issueStateGuard: { statuses: ["todo"], assigneeAgentId: f.nextId, statusVersion: 1 } })); + expect(f.enqueueWakeup.mock.calls[0]![1].idempotencyKey).toEqual(f.enqueueWakeup.mock.calls[1]![1].idempotencyKey); + expect(await db.select().from(activityLog).where(and(eq(activityLog.companyId, f.companyId), eq(activityLog.action, "issue.reassigned")))).toHaveLength(1); + expect(await db.select().from(issueComments).where(eq(issueComments.issueId, f.targetId))).toHaveLength(1); + }); + + it.each(["blocked", "backlog"])("preserves %s and does not wake work prematurely", async status => { + const f = await fixture(status); + await f.authority.execute(f.call); + expect(await f.target()).toMatchObject({ assigneeAgentId: f.nextId, status }); + expect(f.enqueueWakeup).not.toHaveBeenCalled(); + }); + + it("replays across a replacement caller run without a second mutation", async () => { + const f = await fixture(); + const receipt = await f.authority.execute(f.call); + const replacement = randomUUID(); + await db.insert(heartbeatRuns).values({ id: replacement, companyId: f.companyId, agentId: f.agentId, nativeIssueId: f.issueId, runtimeMode: "native", status: "running", invocationSource: "assignment", triggerDetail: "system" }); + await db.update(issues).set({ executionRunId: replacement }).where(eq(issues.id, f.issueId)); + await expect(new PaperclipRunnerToolAuthority(db, { ...f.binding, runId: replacement }).execute(f.call)).resolves.toEqual(receipt); + expect((await f.target()).statusVersion).toBe(1); + }); + + it("repairs a dispatch failure by replaying the committed receipt", async () => { + const f = await fixture(); + f.enqueueWakeup.mockRejectedValueOnce(new Error("queue unavailable")); + await expect(f.authority.execute(f.call)).rejects.toThrow("queue unavailable"); + expect((await f.target()).assigneeAgentId).toBe(f.nextId); + await f.authority.execute(f.call); + expect(f.stopTaskForReassignment).toHaveBeenCalledTimes(1); + expect(f.enqueueWakeup).toHaveBeenCalledTimes(2); + }); + + it("rejects key reuse with different input and stale ownership/version", async () => { + const f = await fixture(); + await f.authority.execute(f.call); + await expect(f.authority.execute({ ...f.call, arguments: { ...f.call.arguments, reason: "Changed intent" } })).rejects.toThrow("idempotency_conflict"); + await expect(f.authority.execute({ ...f.call, arguments: { ...f.call.arguments, idempotencyKey: "stale" } })).rejects.toThrow("reassignment_conflict"); + }); + + it("stops active execution before transferring ownership", async () => { + const f = await fixture("in_progress"); + const oldRun = randomUUID(); + await db.insert(heartbeatRuns).values({ id: oldRun, companyId: f.companyId, agentId: f.agentId, nativeIssueId: f.targetId, runtimeMode: "native", status: "running", invocationSource: "assignment", triggerDetail: "system" }); + await db.update(issues).set({ executionRunId: oldRun, checkoutRunId: oldRun }).where(eq(issues.id, f.targetId)); + f.stopTaskForReassignment.mockImplementationOnce(async () => { + expect((await f.target()).assigneeAgentId).toBe(f.agentId); + await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, oldRun)); + }); + await f.authority.execute(f.call); + expect(await f.target()).toMatchObject({ assigneeAgentId: f.nextId, status: "todo", executionRunId: null, checkoutRunId: null }); + }); + + it("does not transfer ownership if stop fails or a new run races the stop", async () => { + const f = await fixture(); + f.stopTaskForReassignment.mockRejectedValueOnce(new Error("stop failed")); + await expect(f.authority.execute(f.call)).rejects.toThrow("stop failed"); + expect((await f.target()).assigneeAgentId).toBe(f.agentId); + f.stopTaskForReassignment.mockImplementationOnce(async () => { + const runId = randomUUID(); + await db.insert(heartbeatRuns).values({ id: runId, companyId: f.companyId, agentId: f.agentId, status: "running", invocationSource: "assignment", triggerDetail: "system" }); + await db.update(issues).set({ executionRunId: runId }).where(eq(issues.id, f.targetId)); + }); + await expect(f.authority.execute(f.call)).rejects.toThrow("reassignment_conflict"); + expect(f.enqueueWakeup).not.toHaveBeenCalled(); + }); + + it.each(["done", "cancelled", "in_review"])("rejects %s before interrupting work", async status => { + const f = await fixture(status); + await expect(f.authority.execute(f.call)).rejects.toThrow("state_denied"); + expect(f.stopTaskForReassignment).not.toHaveBeenCalled(); + }); + + it("rejects conversations and the caller’s own task", async () => { + const f = await fixture(); + await db.update(issues).set({ conversationAgentId: f.agentId, conversationUserId: "test-user", conversationState: "active", status: "in_progress" }).where(eq(issues.id, f.targetId)); + await expect(f.authority.execute(f.call)).rejects.toThrow("conversation_locked"); + await expect(f.authority.execute({ ...f.call, arguments: { ...f.call.arguments, taskId: f.issueId } })).rejects.toThrow("current_task"); + expect(f.stopTaskForReassignment).not.toHaveBeenCalled(); + }); + + it.each(["planning", "ask"])("denies reassignment from %s mode", async workMode => { + const f = await fixture(); + await db.update(issues).set({ workMode }).where(eq(issues.id, f.issueId)); + await expect(f.authority.execute(f.call)).rejects.toThrow("mode_denied"); + }); + + it("denies foreign targets and unavailable assignees", async () => { + const f = await fixture(), foreign = await fixture(); + await expect(f.authority.execute({ ...f.call, arguments: { ...f.call.arguments, taskId: foreign.targetId } })).rejects.toThrow("task_not_found"); + await expect(f.authority.execute({ ...f.call, arguments: { ...f.call.arguments, assigneeActorId: foreign.agentId } })).rejects.toThrow(); + await db.update(agents).set({ status: "pending_approval" }).where(eq(agents.id, f.nextId)); + await expect(f.authority.execute(f.call)).rejects.toThrow(); + expect(f.stopTaskForReassignment).not.toHaveBeenCalled(); + }); + it("rejects an ownership ABA race through the existing issue service", async () => { + const f = await fixture(); + await issueService(db).update(f.targetId, { assigneeAgentId: f.nextId }); + await issueService(db).update(f.targetId, { assigneeAgentId: f.agentId }); + expect((await f.target()).statusVersion).toBe(2); + await expect(f.authority.execute(f.call)).rejects.toThrow("reassignment_conflict"); + expect(f.stopTaskForReassignment).not.toHaveBeenCalled(); + }); + + it("respects protected assignment policies instead of inheriting board authority", async () => { + const f = await fixture(); + await db.update(agents).set({ permissions: { authorizationPolicy: { protectedAgent: { blockAssignment: true } } } }).where(eq(agents.id, f.nextId)); + await expect(f.authority.execute(f.call)).rejects.toThrow(); + expect((await f.target()).assigneeAgentId).toBe(f.agentId); + expect(f.stopTaskForReassignment).not.toHaveBeenCalled(); + }); + + it("rejects writes after the caller loses its run binding", async () => { + const f = await fixture(); + await db.update(heartbeatRuns).set({ status: "cancelled" }).where(eq(heartbeatRuns.id, f.runId)); + await expect(f.authority.execute(f.call)).rejects.toThrow("binding_not_authorized"); + expect((await f.target()).assigneeAgentId).toBe(f.agentId); + }); + +}); diff --git a/tests/runner-e2e/README.md b/tests/runner-e2e/README.md index 7d54c59de8b..d8a662620e4 100644 --- a/tests/runner-e2e/README.md +++ b/tests/runner-e2e/README.md @@ -132,18 +132,23 @@ minutes, the cell to thirty minutes, and cleanup explicitly deletes the sandbox rather than waiting for Daytona's idle timeout. `agent-chat` (**Persistent Agent Chat**) adds six workflows on `legacy-codex`, -`legacy-claude`, `runner-codex`, and `runner-acpx-claude`: **24 local cells**. +`legacy-claude`, `runner-codex`, and `runner-acpx-claude`: **26 local cells**. They cover continuity across server restart, fresh context after `/new`, Stop/reset/resume, draft/revise/approve/plan handoff, clarification with existing project reuse, and a new project with two repository URLs. Each cell opens the production chat surface and resolves the backing issue through the chat API. The source conversation must settle to `in_review` / `waiting`; handed-off execution tasks must finish with their initial Plan and output documents. -Reset runs are retained separately from the 68 expected provider turns in this +Reset runs are retained separately from the 72 expected provider turns in this suite. Cancelled turns and execution-task runs remain included in billing and cleanup. The production chat directive is injected normally; fixtures do not replace it with completion instructions. Daytona is excluded. +The native Codex and Claude profiles also cover reassignment of existing ready +and backlog tasks. The oracle verifies stable task IDs, preserved descriptions, +assignment audit evidence, exactly one successful successor run and its output +document, no backlog execution, and a usable source conversation after reload. + ```bash # Run these after deterministic checks, with the required provider keys set. pnpm test:e2e:runner -- --id agent-chat.legacy-codex.local.continuity-restart @@ -168,7 +173,7 @@ Both suites save and restore experimental settings. Browser E2E always starts a throwaway instance; never point the authenticated suite at the running demo. Missing provider credentials fail paid preflight and are not passing coverage. -The default `--all` selection is 167 cells (144 local and 23 Daytona) and 363 +The default `--all` selection is 169 cells (146 local and 23 Daytona) and 367 expected paid agent turns. The explicit-only everyday suite adds 35 catalog cells and is excluded from `--all`. Follow-up steps remain ordered within their cell; all other cells are independent. Narrow selectors are strongly recommended while @@ -455,7 +460,7 @@ Set `RUNNER_E2E_AWS_ENABLED=true` to route paid cells to the repository-scoped ephemeral AWS RunsOn fleet selected by `runs-on/fleet=paperclip-public-pr-x64/env=public-ci`. Any other value uses the proven GitHub-hosted `ubuntu-latest` target. Set `RUNNER_E2E_MAX_PARALLEL` to an -integer from 1–100 on AWS (default 100). The 167-cell default selection takes more than +integer from 1–100 on AWS (default 100). The 169-cell default selection takes more than one wave at that limit; use suite selectors for smaller campaigns. The fallback runner retains its 1–57 limit and default of 32. Multi-turn steps are sequential inside their cell while independent cells overlap. Artifacts and merged HTML/JUnit/normalized reports diff --git a/tests/runner-e2e/catalog.test.ts b/tests/runner-e2e/catalog.test.ts index e911c3af523..f0f24e74372 100644 --- a/tests/runner-e2e/catalog.test.ts +++ b/tests/runner-e2e/catalog.test.ts @@ -41,10 +41,10 @@ describe("runner E2E catalog", () => { expect(localIntegrityTasks).toHaveLength(2); expect(openRouterBreadthTasks).toHaveLength(3); expect(runnerSuites.map((suite) => suite.expectedMatrixSize)).toEqual([ - 23, 38, 52, 24, 42, 14, 10, 2, + 23, 38, 52, 26, 42, 14, 10, 2, ]); - expect(validateRunnerCatalog()).toHaveLength(205); - expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(205); + expect(validateRunnerCatalog()).toHaveLength(207); + expect(new Set(runnerMatrix.map((entry) => entry.id)).size).toBe(207); expect( runnerMatrix.filter((entry) => entry.suite.id === "core-compatibility"), ).toHaveLength(42); @@ -68,7 +68,7 @@ describe("runner E2E catalog", () => { (total, execution) => total + execution.task.expectedRunCount, 0, ), - ).toBe(363); + ).toBe(367); expect( runnerTasks.find((task) => task.id === "plan-revise-accept") ?.attemptTimeoutMs, @@ -561,10 +561,10 @@ describe("runner E2E selectors", () => { const jobs = buildMatrixJobs( selectRunnerExecutions(parseRunnerSelectors(["--all"])), ); - expect(jobs).toHaveLength(167); + expect(jobs).toHaveLength(169); expect(jobs.filter((job) => job.needsDaytona)).toHaveLength(23); - expect(jobs.filter((job) => !job.needsDaytona)).toHaveLength(144); - expect(new Set(jobs.map((job) => job.executionId)).size).toBe(167); + expect(jobs.filter((job) => !job.needsDaytona)).toHaveLength(146); + expect(new Set(jobs.map((job) => job.executionId)).size).toBe(169); expect( jobs.find( (job) => diff --git a/tests/runner-e2e/catalog.ts b/tests/runner-e2e/catalog.ts index dde22bde305..c7b2f36d36a 100644 --- a/tests/runner-e2e/catalog.ts +++ b/tests/runner-e2e/catalog.ts @@ -929,8 +929,9 @@ export const runnerSuites: readonly RunnerSuiteFixture[] = [ description: "Task-backed conversations, session resets, and project plan handoff.", groups: ["chat"], profiles: runnerProfiles.filter(profile => ["legacy-codex", "legacy-claude", "runner-codex", "runner-acpx-claude"].includes(profile.id)), - environments: [localEnvironment], tasks: chatTasks, expectedMatrixSize: 24, - definitionMetadata: { version: 1, resetRunsCountedSeparately: true }, + environments: [localEnvironment], tasks: chatTasks, expectedMatrixSize: 26, + excludedExecutionIds: ["legacy-codex", "legacy-claude"].map(profile => `agent-chat.${profile}.local.reassign-task`), + definitionMetadata: { version: 2, resetRunsCountedSeparately: true }, }, ...(process.env.PAPERCLIP_RUNNER_E2E_CONNECTION_REVIEWS === "1" ? [connectionReviewSuite] : []), { diff --git a/tests/runner-e2e/chat-cases.ts b/tests/runner-e2e/chat-cases.ts index a3fcf4cbbd6..2226c9601b6 100644 --- a/tests/runner-e2e/chat-cases.ts +++ b/tests/runner-e2e/chat-cases.ts @@ -10,6 +10,7 @@ export function chatMarker( } export const CHAT_CASES = [ + ["reassign-task", "Reassign existing work and preserve queued context", 2], ["continuity-restart", "Conversation continuity across restart", 3], ["new-session", "Fresh context within preserved history", 2], ["stop-new-resume", "Stop, reset, and resume", 3], diff --git a/tests/runner-e2e/chat-flow.test.ts b/tests/runner-e2e/chat-flow.test.ts index 5b705952dda..79d03798269 100644 --- a/tests/runner-e2e/chat-flow.test.ts +++ b/tests/runner-e2e/chat-flow.test.ts @@ -2,6 +2,7 @@ import { describe, it, expect, vi } from "vitest"; import type { AskUserQuestionsPayload } from "../../packages/shared/src/types/issue.js"; import { assertChatHandoff, + assertChatReassignment, assertChatTaskHandoff, chatQuestionPresentation, chatRunFailure, @@ -95,11 +96,11 @@ describe("chat acceptance contracts", () => { ); expect(chatMarker("CHAT", "one-1")).not.toBe(chatMarker("CHAT", "two-1")); }); - it("has exactly six workflows on the four chosen local profiles", () => { + it("covers existing workflows and native reassignment on the chosen local profiles", () => { const matrix = runnerMatrix.filter( (cell) => cell.suite.id === "agent-chat", ); - expect(matrix).toHaveLength(24); + expect(matrix).toHaveLength(26); expect(new Set(matrix.map((cell) => cell.profile.id))).toEqual( new Set([ "legacy-codex", @@ -108,7 +109,7 @@ describe("chat acceptance contracts", () => { "runner-acpx-claude", ]), ); - expect(new Set(matrix.map((cell) => cell.task.id)).size).toBe(6); + expect(new Set(matrix.map((cell) => cell.task.id)).size).toBe(7); expect( matrix.every( (cell) => @@ -363,3 +364,25 @@ describe("chat acceptance contracts", () => { ).toBe(false); }); }); + + +describe("reassignment outcome oracle", () => { + const evidence = () => ({ readyId: "ready", queuedId: "queued", teammateId: "riley", + tasks: [{ id: "ready", companyId: "co", title: "Ready", status: "done", assigneeAgentId: "riley" }, { id: "queued", companyId: "co", title: "Later", status: "backlog", assigneeAgentId: "riley" }], + runs: [{ id: "successor", companyId: "co", agentId: "riley", status: "succeeded", runtimeMode: "native", contextSnapshot: { issueId: "ready" } }], + audit: [{ action: "issue.reassigned", details: { source: "paperclip_runner_protocol" } }], outputBody: "Launch CHECK123", marker: "CHECK123", + }); + it("accepts persisted ownership and exactly one successful successor", () => { + expect(() => assertChatReassignment(evidence())).not.toThrow(); + }); + it.each(["owner", "duplicate", "missing-run", "missing-audit", "backlog-started", "missing-output"])("rejects %s evidence", defect => { + const data = evidence(); + if (defect === "owner") data.tasks[0]!.assigneeAgentId = "old"; + if (defect === "duplicate") data.tasks.push({ ...data.tasks[0]!, id: "replacement" }); + if (defect === "missing-run") data.runs = []; + if (defect === "missing-audit") data.audit = []; + if (defect === "backlog-started") data.runs.push({ ...data.runs[0]!, id: "early", contextSnapshot: { issueId: "queued" } }); + if (defect === "missing-output") data.outputBody = "I reassigned it"; + expect(() => assertChatReassignment(data)).toThrow(); + }); +}); diff --git a/tests/runner-e2e/chat-flow.ts b/tests/runner-e2e/chat-flow.ts index 6becdb69e7b..c916a04496c 100644 --- a/tests/runner-e2e/chat-flow.ts +++ b/tests/runner-e2e/chat-flow.ts @@ -249,12 +249,30 @@ export async function sendChatMessage(page: Page, message: string) { await page.getByTestId("task-chat-composer-send").last().click(); } + +/** Independent durable oracle: prose alone cannot make a reassignment pass. */ +export function assertChatReassignment(input: { + readyId: string; queuedId: string; teammateId: string; tasks: ChatIssue[]; runs: ChatRun[]; + audit: Array<{ action: string; details?: Record }>; outputBody: string; marker: string; +}) { + expect(input.tasks.map(task => task.id).sort()).toEqual([input.readyId, input.queuedId].sort()); + expect(input.tasks.find(task => task.id === input.readyId)).toMatchObject({ status: "done", assigneeAgentId: input.teammateId }); + expect(input.tasks.find(task => task.id === input.queuedId)).toMatchObject({ status: "backlog", assigneeAgentId: input.teammateId }); + const successor = input.runs.filter(run => run.contextSnapshot?.issueId === input.readyId); + expect(successor).toHaveLength(1); + expect(successor[0]).toMatchObject({ agentId: input.teammateId, status: "succeeded", runtimeMode: "native" }); + expect(input.runs.filter(run => run.contextSnapshot?.issueId === input.queuedId)).toHaveLength(0); + expect(input.audit.filter(row => row.action === "issue.reassigned" && row.details?.source === "paperclip_runner_protocol")).toHaveLength(1); + expect(input.outputBody).toContain(input.marker); +} + export async function runChatFlow(input: { page: Page; api: RunnerApi; fixtures: LiveFixtureValues; execution: MatrixExecution; nonce: string; + workspacePath: string; restart: () => Promise; observe: (issue: ChatIssue, runs: ChatRun[]) => void; capture: (id: string, label: string, file: string) => Promise; @@ -465,6 +483,39 @@ export async function runChatFlow(input: { } expect(issue!.id).toBe(initialId); await noTasks(); + } else if (caseId === "reassign-task") { + const config = execution.profile.buildAgent({ + environmentId: f.environment.id, environmentFixtureId: execution.environment.id, + workspacePath: input.workspacePath, secretRefs: f.secretRefs, executionId: nonce, + }); + const teammate = await api.post<{ id: string }>(`/api/companies/${f.company.id}/agents`, { + ...config, name: "Riley Reassignment", role: "engineer", reportsTo: f.agent.id, + instructionsBundle: { entryFile: "AGENTS.md", files: { "AGENTS.md": "Complete the assigned work and save the requested Paperclip document." } }, + }); + const queued = await api.post(`/api/companies/${f.company.id}/issues`, { + title: `Later checklist ${nonce}`, description: `Preserve this deferred scope ${draftMarker}.`, + status: "backlog", assigneeAgentId: f.agent.id, + }); + const ready = await api.post(`/api/companies/${f.company.id}/issues`, { + title: `Ready checklist ${nonce}`, status: "todo", + description: `Write a short launch checklist as a Paperclip document attached to this task, including ${marker}. Complete this task after saving it.`, + }); + await turn(`Assign the existing Ready checklist ${nonce} task to Riley Reassignment so Riley completes it. Also move the existing Later checklist ${nonce} task from you to Riley, keeping it in backlog. Preserve both tasks and their descriptions. Explain the handoff briefly here. Do not create replacement tasks or start the backlog work.`, 2); + const observedTasks = await tasks(); + const readyAfter = await api.get(`/api/issues/${ready.id}`); + const queuedAfter = await api.get(`/api/issues/${queued.id}`); + const output = await readChatOutputDocument(api, ready.id, marker); + const activity = await api.get }>>(`/api/issues/${ready.id}/activity`); + assertChatReassignment({ readyId: ready.id, queuedId: queued.id, teammateId: teammate.id, tasks: observedTasks, runs, + audit: activity, outputBody: output.body, marker }); + expect(queuedAfter).toMatchObject({ assigneeAgentId: teammate.id, status: "backlog", description: `Preserve this deferred scope ${draftMarker}.` }); + expect(readyAfter).toMatchObject({ assigneeAgentId: teammate.id, status: "done" }); + expect(issue!).toMatchObject({ assigneeAgentId: f.agent.id, conversationState: "waiting" }); + await page.reload({ waitUntil: "domcontentloaded", timeout: 60_000 }); + await expect(page.getByTestId("task-chat-composer-input")).toBeVisible(); + await expect(page.getByRole("link", { name: readyAfter.identifier! }).first()).toBeVisible(); + await input.capture("chat-reassignment", "Reassignment completed in Agent Chat", "chat-reassignment.png"); + await input.evidence("chat-reassignment.json", { tasks: observedTasks, runs, activity, output, teammateId: teammate.id }); } else { let existingProject: { id: string; name: string } | undefined; let acceptedPlan: Plan | undefined; diff --git a/tests/runner-e2e/runner.spec.ts b/tests/runner-e2e/runner.spec.ts index 19ff9d9cb59..1523fa36706 100644 --- a/tests/runner-e2e/runner.spec.ts +++ b/tests/runner-e2e/runner.spec.ts @@ -814,7 +814,7 @@ for (const execution of executions) { matcherResults = story.evidence.checks.map(check => ({ matcher: { kind: "json_path" as const, path: check.id, expected: true }, passed: check.passed, detail: check.detail })); } else if (execution.task.flow === "agent_chat") { const chat = await runChatFlow({ - page, api, fixtures, execution, nonce, + page, api, fixtures, execution, nonce, workspacePath, restart: () => restartIsolatedPaperclipServer({ api, requestId: `chat-${nonce}`, deadlineAt: startedAtMs + deadlineMs }), observe: (chatIssue, chatRuns) => { issue = chatIssue; selectedRuns = chatRuns; }, capture: captureScreenshot,