mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-08 01:57:31 +01:00
feat: let agents name prompt-only tasks (#14761)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work. > - Users create tasks with a title and a description. > - A required title adds work when the prompt already explains the request. > - An agent can name the task once it reads that request. > - This pull request accepts prompt-only tasks and starts them with a short prompt slice. > - A scoped title tool lets the assigned agent replace that slice early without changing execution state. > - A live browser eval checks the real agent call, saved title, audit entry, and preservation of user titles. ## Linked Issues or Issue Description **Subsystem affected** Cross-cutting: task creation, shared contracts, database, server, runner tools, and board UI. **Problem or motivation** Users must currently write a title before they can submit a detailed task prompt. The agent has enough context to write a useful title itself. **Proposed solution** Make the title optional when a description is present. Save the first 120 characters of the normalized prompt as a provisional title. Ask the assigned agent to call `set_task_title` early. Use an atomic provisional-title guard to preserve titles supplied or edited by users. Keep explicit titles supported. Related: #14543 and #14556 concern empty-title submission. This change intentionally enables that submission when a prompt is present, instead of requiring a title. ## What Changed - Add the `titleNeedsGeneration` field with an idempotent migration. Keep existing titles unchanged. - Add `PUT /api/issues/:id/title` and the native and legacy `set_task_title` tool. Enforce company access, active-run ownership, shared, bounded retry receipts across native/HTTP calls, and transactional audit logging. Refresh external-object links after commit, with the same feature gate and plugin detectors as ordinary title edits. - Add early naming guidance in Standard, Ask, and Plan task context. Preserve the description, status, and assignment. - Allow prompt-only root and child task creation, plus draft restoration in the New Task dialog. Keep user titles supported. - Add an opt-in Product E2E suite for prompt-only Standard and Ask tasks, plus an explicit-title control. It checks actual provider calls within the first five tools, persisted state, audit attribution, and the reloaded UI. - Preserve a closed vocabulary of API key maintenance phrases in declared prose while rejecting opaque credential suffixes. Add one bounded naming retry after wording is rejected, without treating the rejected call as a saved title. - Repair the native cleanup receipt check exposed during full verification: accept matching input digests, retain legacy input checks, and reject conflicting receipts. ## Verification - Live Product E2E on `f43478473800e3a46b85c5ee79677efdb15108e7`: **3/3 passed** with native Codex `gpt-5.4-mini`, first attempts only, automatic retries disabled. Standard and Ask each saved “Rotate expired API key” on their first tool call, with matching persisted state and a single same-run audit entry. The explicit-title control retained its user title with zero title writes. All three verified the reloaded browser UI. - Campaign: `local-2026-09-30T21-30-11-021Z`. Earlier failed campaigns are retained separately; they exposed credential-prose handling and prompted the naming recovery fix. No failed result was regraded or deleted. - Reproduce with `pnpm test:e2e:runner -- --id task-titles.runner-codex-mini.local.prompt-title-standard --id task-titles.runner-codex-mini.local.prompt-title-ask --id task-titles.runner-codex-mini.local.preserve-explicit-title --max-automatic-retries 0` and an authorized provider key. - Full `pnpm -r typecheck` and `pnpm build` passed on the latest commit. The runner build used the configured external eval source tree. - Product E2E unit suite: **61 files, 818 tests passed**; E2E typecheck and UI token gates passed. - Title API/native regressions cover prompt-only and explicit child creation, user edits, ownership/company isolation, external reference refresh, cross-surface retry replay, and the 64-key limit without receipt eviction. All passed. Prompt-context coverage: **44 tests passed**. - Rust credential regressions: **35 tests passed**, including benign maintenance qualifiers and opaque credential rejection in every declared prose field. Catalog/report reconciliation: **28 tests passed**. Native recovery: **560 tests passed**. - Broad local `pnpm test:run`: **14,555 tests passed** in the general server group; two suites failed to initialize embedded PostgreSQL and the existing 40,000-file Git streaming stress test exceeded its 300-second macOS timeout. All three suites then passed in isolation (**5 tests passed**) without code or timeout changes. The original full local command exited nonzero and is not being represented as a clean full run. - Latest-head GitHub checks are green: **53 passed, 4 skipped, zero failed or pending**, including all test shards and the canary packaging dry run. Greptile reviewed the same commit at **5/5**, with zero unresolved review threads. ## Risks - The additive database field must reach the server and UI together. The migration uses `IF NOT EXISTS` and defaults existing tasks to a final title. - Title generation depends on the assigned agent running. Tasks without a run keep their provisional title. - Live qualification covers the native Codex path in Standard and Ask modes. API/legacy and Plan behavior have deterministic coverage. - The credential-prose exception validates the entire suffix against a closed maintenance vocabulary. Unknown suffixes, assignments, quoted values, credential prefixes, and diagnostics retain strict checks. ## Model Used OpenAI Codex, based on GPT-6, with reasoning, tool use, and code execution. The exact deployment ID and context window are not exposed in this session. The live eval uses the native Codex `gpt-5.4-mini` profile. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] All Paperclip CI gates are green - [x] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
This commit is contained in:
1 parent
d6d67b00d3
commit
018993140f
63 files changed
+2279
-226
No files matched your search
@@ -2694,6 +2694,9 @@ describe("retained native cleanup activation", () => {
|
||||
"distinct_provider_account",
|
||||
"wrong_provider_account",
|
||||
"wrong_result_digest",
|
||||
"digest_only_command",
|
||||
"wrong_command_digest",
|
||||
"conflicting_command_digest",
|
||||
"wrong_semantic_input",
|
||||
"wrong_contract",
|
||||
"wrong_turn",
|
||||
@@ -2927,7 +2930,9 @@ describe("retained native cleanup activation", () => {
|
||||
payload: {
|
||||
callId: semantic.callId,
|
||||
operationId: semantic.operationId,
|
||||
input: semanticInput,
|
||||
...(mode === "digest_only_command" || mode === "wrong_command_digest"
|
||||
? { inputDigest: mode === "wrong_command_digest" ? "wrong" : nativeSha256(semanticInput) }
|
||||
: { input: semanticInput, ...(mode === "conflicting_command_digest" ? { inputDigest: "wrong" } : {}) }),
|
||||
correlation,
|
||||
sourceEventId: rawInput.sourceEventId,
|
||||
sourceEventType: rawInput.eventType,
|
||||
@@ -3188,6 +3193,8 @@ describe("retained native cleanup activation", () => {
|
||||
![
|
||||
"foreign_event",
|
||||
"wrong_result_digest",
|
||||
"wrong_command_digest",
|
||||
"conflicting_command_digest",
|
||||
"wrong_semantic_input",
|
||||
"wrong_contract",
|
||||
"wrong_turn",
|
||||
@@ -3793,6 +3800,8 @@ describe("retained native cleanup activation", () => {
|
||||
"foreign_event",
|
||||
"nonempty_root",
|
||||
"wrong_result_digest",
|
||||
"wrong_command_digest",
|
||||
"conflicting_command_digest",
|
||||
"wrong_semantic_input",
|
||||
"wrong_contract",
|
||||
"wrong_turn",
|
||||
@@ -3802,6 +3811,7 @@ describe("retained native cleanup activation", () => {
|
||||
"wrong_provider_account",
|
||||
].includes(mode);
|
||||
const succeeds = [
|
||||
"digest_only_command",
|
||||
"home_paginated",
|
||||
"canonical_source",
|
||||
"canonical_claim_commit_stalled",
|
||||
|
||||
@@ -2193,7 +2193,13 @@ export function retainedNativeCleanupJournalMatches(input: {
|
||||
payload.isError === false &&
|
||||
payload.sourceEventId === event.sourceEventId &&
|
||||
payload.sourceEventType === event.eventType &&
|
||||
canonicalJson(payload.input) === canonicalJson(semantic.input) &&
|
||||
// Current receipts retain a digest; older journals retain the input.
|
||||
// If both exist, both must bind to the accepted semantic input.
|
||||
(payload.inputDigest === undefined
|
||||
? canonicalJson(payload.input) === canonicalJson(semantic.input)
|
||||
: payload.inputDigest === nativeSha256(semantic.input) &&
|
||||
(payload.input === undefined ||
|
||||
canonicalJson(payload.input) === canonicalJson(semantic.input))) &&
|
||||
canonicalJson(payload.correlation) ===
|
||||
canonicalJson(semantic.correlation) &&
|
||||
record(record(command.result).result).callId === semantic.callId
|
||||
@@ -7268,6 +7274,7 @@ export async function executePaperclipNativeSession(input: {
|
||||
runnerRemoteCodexNpmSpec?: string | null;
|
||||
runnerRemoteProviderPackPath?: string | null;
|
||||
stopTaskForReassignment?: (target: { companyId: string; issueId: string; agentId: string; runId: string | null }) => Promise<void>;
|
||||
syncIssueExternalObjects?: (issueId: string) => Promise<void>;
|
||||
enqueueWakeup?: (
|
||||
agentId: string,
|
||||
options: {
|
||||
@@ -10641,6 +10648,7 @@ export async function createRunnerdBackend(input: {
|
||||
toolTrace?: NativeToolTrace;
|
||||
onLog?: (stream: "stdout" | "stderr", chunk: string) => Promise<void>;
|
||||
stopTaskForReassignment?: (target: { companyId: string; issueId: string; agentId: string; runId: string | null }) => Promise<void>;
|
||||
syncIssueExternalObjects?: (issueId: string) => Promise<void>;
|
||||
enqueueWakeup?: (
|
||||
agentId: string,
|
||||
options: {
|
||||
@@ -10783,6 +10791,7 @@ async function createRunnerdBackendWithinSessionClaim(
|
||||
currentWakeComments: currentWakeComments ?? undefined,
|
||||
chatAttachmentReadScope: input.chatAttachmentReadScope,
|
||||
stopTaskForReassignment: input.stopTaskForReassignment,
|
||||
syncIssueExternalObjects: input.syncIssueExternalObjects,
|
||||
enqueueWakeup: input.enqueueWakeup,
|
||||
});
|
||||
const authorityEpoch = new SessionToolAuthorityEpoch(
|
||||
|
||||
@@ -99,7 +99,7 @@ describe("PaperclipRunnerToolAuthority", () => {
|
||||
issueId,
|
||||
runId,
|
||||
});
|
||||
expect(authority.definitions()).toHaveLength(34);
|
||||
expect(authority.definitions()).toHaveLength(35);
|
||||
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 fabricate answers");
|
||||
|
||||
@@ -1,3 +1,7 @@
|
||||
import { setIssueTitle } from "../issue-title.js";
|
||||
import { externalObjectService } from "../external-objects.js";
|
||||
import { instanceSettingsService } from "../instance-settings.js";
|
||||
import { setIssueTitleSchema } from "@paperclipai/shared";
|
||||
import { authorizeInstructionCommit } from "../agent-instruction-authorization.js";
|
||||
import { executeAgentInstructionTool } from "./agent-instruction-tools.js";
|
||||
import { createReadStream } from "node:fs";
|
||||
@@ -87,7 +91,7 @@ import {
|
||||
const IMPLEMENTED_OPERATIONS = new Set([
|
||||
"read_agent_instructions", "update_agent_instructions", "get_agent_instruction_history", "restore_agent_instructions",
|
||||
"search_api", "call_api", "hire_agent",
|
||||
"get_task_context", "get_task_history", "search_tasks", "report_progress",
|
||||
"get_task_context", "get_task_history", "search_tasks", "report_progress", "set_task_title",
|
||||
"request_human_input",
|
||||
"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",
|
||||
@@ -120,6 +124,7 @@ type Binding = {
|
||||
readRemoteWorkspaceFile?: RemoteWorkspaceFileReader;
|
||||
currentWakeComments?: CurrentWakeCommentsBinding;
|
||||
chatAttachmentReadScope?: NativeChatAttachmentReadScope;
|
||||
syncIssueExternalObjects?: (issueId: string) => Promise<void>;
|
||||
stopTaskForReassignment?: (target: { companyId: string; issueId: string; agentId: string; runId: string | null }) => Promise<void>;
|
||||
enqueueWakeup?: (agentId: string, options: {
|
||||
source: "assignment";
|
||||
@@ -547,6 +552,7 @@ export class PaperclipRunnerToolAuthority {
|
||||
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_task_title": return this.#setTaskTitle(input);
|
||||
case "set_dependencies": return this.#setDependencies(input);
|
||||
case "register_deliverable": return this.#registerDeliverable(input);
|
||||
default: throw new Error("paperclip_runner_tool_not_bound");
|
||||
@@ -1246,6 +1252,25 @@ export class PaperclipRunnerToolAuthority {
|
||||
return result;
|
||||
}
|
||||
|
||||
async #setTaskTitle(input: Record<string, unknown>): Promise<unknown> {
|
||||
const titleInput = setIssueTitleSchema.parse(input);
|
||||
requiredString(titleInput.idempotencyKey);
|
||||
// Use the shared title receipt so native and HTTP retries have the same
|
||||
// identity and cannot overwrite a later user edit when switching surfaces.
|
||||
const { result, publication } = await this.db.transaction(async (tx) => {
|
||||
await this.#lockAuthorizedMutationContext(tx as unknown as Db);
|
||||
return setIssueTitle(tx as unknown as Db, this.binding.companyId, this.binding.issueId, titleInput, {
|
||||
actorType: "agent", actorId: this.binding.agentId, agentId: this.binding.agentId, runId: this.binding.runId,
|
||||
});
|
||||
});
|
||||
if (publication) publishActivity(publication);
|
||||
const syncExternalObjects = this.binding.syncIssueExternalObjects ?? externalObjectService(this.db, {
|
||||
enabled: async () => (await instanceSettingsService(this.db).getExperimental()).enableExternalObjects === true,
|
||||
}).syncIssueSafely;
|
||||
await syncExternalObjects(this.binding.issueId);
|
||||
return result;
|
||||
}
|
||||
|
||||
async #setDependencies(input: Record<string, unknown>): Promise<unknown> {
|
||||
const idempotencyKey = requiredString(input.idempotencyKey);
|
||||
if (!Array.isArray(input.blockedByTaskIds)) {
|
||||
@@ -1926,6 +1951,7 @@ function redactedTask(task: typeof issues.$inferSelect) {
|
||||
companyId: task.companyId,
|
||||
identifier: task.identifier,
|
||||
title: task.title,
|
||||
titleNeedsGeneration: task.titleNeedsGeneration,
|
||||
description: task.description,
|
||||
status: task.status,
|
||||
statusVersion: task.statusVersion,
|
||||
|
||||
@@ -42,6 +42,7 @@ function words(text: string): string[] {
|
||||
}
|
||||
|
||||
function dedicatedTools(method: string, path: string): string[] {
|
||||
if (/\/issues\/\{[^}]+\}\/title$/.test(path) && method === "PUT") return ["set_task_title"];
|
||||
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/file$/.test(path)) return method === "GET" ? ["read_agent_instructions"] : method === "PUT" ? ["update_agent_instructions"] : [];
|
||||
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/history$/.test(path) && method === "GET") return ["get_agent_instruction_history"];
|
||||
if (/\/agents\/\{[^}]+\}\/instructions-bundle\/revision\/\{[^}]+\}$/.test(path) && method === "GET") return ["read_agent_instructions"];
|
||||
@@ -83,6 +84,7 @@ export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()):
|
||||
const restriction = runnerApiRestriction(method, path);
|
||||
const conversationalConfirmation = method === "POST"
|
||||
&& /^\/api\/issues\/\{[^}]+\}\/interactions\/\{[^}]+\}\/resolve-from-comment$/.test(path);
|
||||
const taskTitleUpdate = method === "PUT" && /^\/api\/issues\/\{[^}]+\}\/title$/.test(path);
|
||||
const skillReference = runnerApiReference[`${method} ${path.replace(/\{[^}]+\}/g, "{}")}`];
|
||||
const protocol = !path.startsWith("/api/") || /\/(oauth|auth|runtime-tools|mcp|ws)(\/|$)/.test(path)
|
||||
|| /\/(claude-login|login-sessions|start-authorization|finalize-oauth-access)(\/|$)/.test(path)
|
||||
@@ -109,7 +111,7 @@ export function buildRunnerApiCatalog(document: Json = buildOpenApiDocument()):
|
||||
dedicatedToolGuidance: dedicatedTools(method, path).includes("hire_agent")
|
||||
? "Use hire_agent for native teammate identity and persona fields. It fixes the reportsTo and source task context and inherits the caller's native runtime; never use call_api to supply adapter, environment, or credential configuration."
|
||||
: "Use an available dedicated tool for its supported fields. Inspect that tool's advertised schema; call_api may be used for additional API fields, subject to lifecycle restrictions.",
|
||||
allowedModes: method === "GET" || method === "HEAD" ? ["standard", "ask", "planning", "skill_test"]
|
||||
allowedModes: method === "GET" || method === "HEAD" || taskTitleUpdate ? ["standard", "ask", "planning", "skill_test"]
|
||||
: conversationalConfirmation ? ["standard", "planning", "skill_test"] : ["standard", "skill_test"],
|
||||
...(skillReference ? { skillReference } : {}),
|
||||
});
|
||||
@@ -167,6 +169,6 @@ export function searchRunnerApi(value: unknown) {
|
||||
return {
|
||||
results, total: matches.length,
|
||||
nextCursor: offset + limit < matches.length ? `${fingerprint}:${offset + limit}` : null,
|
||||
guidance: "Prefer an available dedicated tool when it supports the required operation and parameters. API permissions still apply. Protocol endpoints require their existing clients. Ask and Plan permit only reads through call_api.",
|
||||
guidance: "Prefer an available dedicated tool when it supports the required operation and parameters. API permissions still apply. Protocol endpoints require their existing clients. Ask and Plan permit reads and active-task title updates; Plan also permits conversational confirmation recording. Check each operation's allowedModes.",
|
||||
};
|
||||
}
|
||||
@@ -58,6 +58,17 @@ describe("bounded response capture receipts", () => {
|
||||
});
|
||||
|
||||
describe("runner API catalog", () => {
|
||||
it.each(["standard", "ask", "planning", "skill_test"])("permits the title-only API in %s mode", workMode => {
|
||||
const operationId = "PUT /api/issues/{id}/title";
|
||||
const { operation } = validateRunnerApiCall({
|
||||
operationId,
|
||||
pathParams: { id: context.issueId },
|
||||
body: { title: "Fix sign-in redirect", onlyIfProvisional: true },
|
||||
}, { ...context, workMode });
|
||||
expect(operation.allowedModes).toContain(workMode);
|
||||
expect(operation.dedicatedTools).toEqual(["set_task_title"]);
|
||||
expect(operation.requestBody?.content["application/json"].schema.additionalProperties).toBe(false);
|
||||
});
|
||||
it("advertises the conversational recording exception without general approval authority", () => {
|
||||
const operationId = "POST /api/issues/{id}/interactions/{interactionId}/resolve-from-comment";
|
||||
const operation = runnerApiOperation(operationId);
|
||||
|
||||
Reference in new issue
Block a user