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:
DottaandPaperclip authored and GitHub committed 2026-09-30 16:48:24 -05:00
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);