diff --git a/doc/execution-semantics.md b/doc/execution-semantics.md index 17ee6c4f445..b743e309a61 100644 --- a/doc/execution-semantics.md +++ b/doc/execution-semantics.md @@ -2529,6 +2529,14 @@ wake from a server-verified human card response references that entry in `humanResponses`, whose answer is already present in the current request context. Agent/tool outcomes and generated summaries are not promoted to human direction. +Standing agent chats complete the current response stage. Each pending request +keeps its criterion identity and source, with instructions to address it within +that stage. Authorized chat actions require verified results. Delegation requires +a verified handoff and a reply; the worker's task remains separate. An unavailable +action requires a truthful limitation and supported handoff. Ordinary task +contracts still require the requested work itself, and an admitted run retains +its saved contract during recovery. + These are model instructions, not additional execution or permission gates. Contracts reference the existing brief and answers instead of copying them again. Resumed sessions keep the existing message-delta path; fresh sessions receive the diff --git a/server/src/services/heartbeat/runtime-selection.test.ts b/server/src/services/heartbeat/runtime-selection.test.ts index cdfae206f44..c640732ea77 100644 --- a/server/src/services/heartbeat/runtime-selection.test.ts +++ b/server/src/services/heartbeat/runtime-selection.test.ts @@ -12,7 +12,7 @@ import { nativeRuntimeContextFixture } from "../native-runtime/runtime-context.t import { prepareNativeWorkspaceSync } from "../native-runtime/native-workspace-sync.js"; import { resolveHeartbeatNativeRuntimeMode } from "../native-runtime/runtime-mode.js"; import { claimNativeRestartRecoveries } from "../native-runtime/native-restart-recovery.js"; -import { canonicalNativeRuntimeContextDigest } from "../../vendor/paperclip-runner/index.js"; +import { buildNativeModelEnvelope, canonicalNativeRuntimeContextDigest } from "../../vendor/paperclip-runner/index.js"; import { selectHeartbeatRuntime, type HeartbeatRuntimeSelectionInput } from "./runtime-selection.js"; import { legacyControllerClaim } from "../legacy-controller-lease.js"; @@ -76,6 +76,28 @@ describe.skipIf(!support.supported)("heartbeat runtime selection boundary", () = const coordinators = (id: string) => db.select().from(nativeRunFinalizations).where(eq(nativeRunFinalizations.runId, id)); const sandbox = { kind: "remote" as const, transport: "sandbox" as const, remoteCwd: "/workspace", reusableLeaseConfigured: false }; + it("persists and admits a standing-chat response contract without requiring the unavailable action", async () => { + const { input, issue, agent, run } = await fixture(); + const [conversation] = await db.update(issues).set({ title: "Chat with CEO", description: null, + conversationAgentId: agent.id, conversationUserId: "runtime-test-operator", conversationState: "active" }) + .where(eq(issues.id, issue.id)).returning(); + input.task.issueRef = conversation!; + input.task.context.conversationMode = true; + const request = "Restart this instance."; + input.task.safeWakeComments = [{ id: randomUUID(), body: request }]; + const selected = await selectHeartbeatRuntime(db, input); + if (!selected.selected || !selected.nativeExecution) throw new Error("Expected native selection"); + const contract = selected.nativeExecution.completionContract.contract; + expect(contract.objective).toMatch(/current conversation response/i); + expect(contract.criteria).toEqual([{ id: "objective", requirement: expect.stringContaining(request) }]); + expect(contract.criteria[0]!.requirement).not.toBe(request); + const [persisted] = await db.select().from(completionContracts).where(eq(completionContracts.issueId, issue.id)); + expect(persisted!.contractJson).toEqual(contract); + expect(selected.nativeExecution.completionContract.sha256).toBe(persisted!.canonicalSha256); + expect((await read(run.id)).runnerProfileJson?.nativeExecutionInput).toEqual(selected.nativeExecution); + expect(buildNativeModelEnvelope(selected.nativeExecution).completionContract).toEqual(contract); + }); + it("keeps recovery out of live preparation before the workspace descriptor exists", async () => { const { input, run, issue } = await fixture(); input.workspace.executionTarget = sandbox; diff --git a/server/src/services/native-runtime/completion-contracts.test.ts b/server/src/services/native-runtime/completion-contracts.test.ts index 08c31b7a192..7f54c74bc6c 100644 --- a/server/src/services/native-runtime/completion-contracts.test.ts +++ b/server/src/services/native-runtime/completion-contracts.test.ts @@ -11,6 +11,38 @@ import { } from "./completion-contracts.js"; describe("buildNativeCompletionContract", () => { + it("scopes standing-chat criteria to the response while preserving every current request and source", () => { + const issue = { id: "chat", title: "Chat with CEO", description: null, conversationAgentId: "ceo" }; + const comments = [ + { id: "operator-request", body: "Restart this instance." }, + { id: "handoff-request", body: "Have Morgan draft the update and send it here when finished." }, + ]; + const pending = nativeCompletionRequestsWithSources(comments); + const contract = buildNativeCompletionContract(issue, { immediateRequests: pending.requests, humanResponseId: "answer" }); + expect(contract.objective).toMatch(/conversation response/i); + expect(contract.objective).toMatch(/verified|verify/i); + expect(contract.objective).toMatch(/delegated/i); + expect(contract.objective).toMatch(/unavailable/i); + expect(contract.criteria.map(row => row.id)).toEqual(["pending_comment_1", "pending_comment_2", "human_response"]); + for (const [index, comment] of comments.entries()) { + const requirement = contract.criteria[index]!.requirement; + expect(requirement).not.toBe(comment.body); + expect(requirement).toContain(comment.body); + expect(requirement).toMatch(/current authorized conversation response/i); + expect(requirement.split(comment.body)).toHaveLength(2); + } + expect(contract.criteria[2]!.requirement).toContain('"answer"'); + expect(buildNativeCompletionContractSources({ issue, immediateRequests: pending.requests, immediateRequestSources: pending.sources })) + .toEqual(comments.map((comment, index) => ({ id: `pending_comment_${index + 1}`, source: nativeCompletionSource("comment", comment.id, comment.body) }))); + }); + + it.each([undefined, null])("keeps an ordinary task's action criterion strict even with a chat-like title (%s)", (conversationAgentId) => { + const issue = { title: "Chat with CEO", description: null, conversationAgentId }; + const request = "Restart this instance."; + expect(buildNativeCompletionContract(issue, { immediateRequest: request }).criteria) + .toEqual([{ id: "objective", requirement: request }]); + }); + it("uses the task description as the single initial criterion", () => { expect(buildNativeCompletionContract({ title: "Ship the runner", diff --git a/server/src/services/native-runtime/completion-contracts.ts b/server/src/services/native-runtime/completion-contracts.ts index 7a4fc728bfd..e26152692bb 100644 --- a/server/src/services/native-runtime/completion-contracts.ts +++ b/server/src/services/native-runtime/completion-contracts.ts @@ -87,7 +87,7 @@ export function resolveNativeCompletionPolicy(issue: { } export function buildNativeCompletionContract( - issue: { title: string; description: string | null }, + issue: { title: string; description: string | null; conversationAgentId?: string | null }, options: { readonly revision?: number; readonly immediateRequest?: string | null; @@ -104,19 +104,26 @@ export function buildNativeCompletionContract( .filter((request) => request.length > 0); const humanResponseId = options.humanResponseId?.trim(); const hasFollowUp = immediateRequests.length > 0 || Boolean(humanResponseId); + const conversation = Boolean(issue.conversationAgentId); + const requirement = (request: string) => conversation + ? `Address this request in the current authorized conversation response: ${request}` + : request; // Reference existing context rather than copying the brief/history into every // follow-up contract. Keep this guidance stable across comments and resumes. const followUpObjective = "Complete the current authorized stage using the task brief and current user direction in the supplied context. Later human direction replaces conflicting scope; preserve other requirements, assigned-skill instructions, and approval gates. Apply authenticated humanResponses only to their question or decision. Clarification is not approval. If acceptance is required, propose or save the requested plan and wait before executing."; + const conversationObjective = "Complete the current conversation response using the supplied context and current user direction. Carry out requested chat actions within current authority and verify their results before claiming success. For delegated work, verify the saved handoff and report it without waiting for or performing the worker's task. For an unavailable action, explain the specific limitation and supported handoff without claiming the action occurred. Finishing this response leaves underlying delegated or unavailable work incomplete until separately verified. Later human direction replaces conflicting scope; preserve other requirements, assigned-skill instructions, and approval gates. Apply authenticated humanResponses only to their question or decision. Clarification is not approval."; return { revision: String(options.revision ?? 1), - objective: hasFollowUp - ? followUpObjective - : issue.title, + objective: conversation + ? conversationObjective + : hasFollowUp + ? followUpObjective + : issue.title, criteria: hasFollowUp ? [ ...immediateRequests.map((request, index) => ({ id: immediateRequests.length === 1 ? "objective" : `pending_comment_${index + 1}`, - requirement: request, + requirement: requirement(request), })), ...(humanResponseId ? [{ id: "human_response", @@ -127,7 +134,7 @@ export function buildNativeCompletionContract( { id: "objective", requirement: - issue.description?.trim() || `Complete: ${issue.title}`, + requirement(issue.description?.trim() || (conversation ? "Respond to the current conversation context." : `Complete: ${issue.title}`)), }, ], }; @@ -157,6 +164,7 @@ export async function ensureNativeCompletionContract(input: { title: string; description: string | null; reviewPolicy?: string | null; + conversationAgentId?: string | null; }; actorId: string; immediateRequest?: string | null;