Skip to content

Commit f059311

Browse files
committed
fix(agent): preserve named policy and retention
1 parent 68b8501 commit f059311

6 files changed

Lines changed: 112 additions & 14 deletions

File tree

packages/junior/src/chat/agent-invocations/README.md

Lines changed: 5 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,13 +7,15 @@ stores the terminal result for its parent to read later.
77
## Records
88

99
- An **agent binding** maps one name within a parent agent conversation to one
10-
destinationless child conversation. Reusing the name reuses that child's
11-
history.
10+
destinationless child conversation and its reasoning policy. Reusing the
11+
name reuses that child's history; an omitted reasoning level inherits the
12+
binding, while an explicit mismatch is rejected.
1213
- An **agent invocation** is one retry-safe task sent to a child. Its
1314
`invocationId` is derived from the parent conversation and caller-supplied
1415
idempotency key.
1516
- An invocation without a name gets an invocation-scoped child conversation.
16-
- Child conversation lineage is immutable. Recursive delegation is disabled
17+
- Child conversation lineage is immutable. Bindings and invocation content are
18+
purged with their root conversation tree. Recursive delegation is disabled
1719
until depth, cancellation, and authority rules are defined.
1820

1921
SQL owns bindings, invocation status, bounded execution authority, and terminal

packages/junior/src/chat/agent-invocations/store.ts

Lines changed: 29 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -223,9 +223,27 @@ export async function createAgentInvocation(
223223
input.agentName ? childConversationId : invocationId
224224
}`;
225225
return await getSqlExecutor().withLock(lockName, async () => {
226+
const existingBinding = input.agentName
227+
? await getAgentBinding({
228+
name: input.agentName,
229+
parentConversationId: input.parentConversationId,
230+
})
231+
: undefined;
232+
if (
233+
existingBinding &&
234+
input.reasoningLevel !== undefined &&
235+
input.reasoningLevel !== existingBinding.reasoningLevel
236+
) {
237+
throw new Error(
238+
`Named agent binding policy changed for ${input.agentName}`,
239+
);
240+
}
241+
const effectiveInput = existingBinding?.reasoningLevel
242+
? { ...input, reasoningLevel: existingBinding.reasoningLevel }
243+
: input;
226244
const existing = await getAgentInvocation(invocationId);
227245
if (existing) {
228-
if (!sameCreateInput(existing, input)) {
246+
if (!sameCreateInput(existing, effectiveInput)) {
229247
throw new Error(
230248
`Agent invocation idempotency key was reused with different input for ${invocationId}`,
231249
);
@@ -249,7 +267,7 @@ export async function createAgentInvocation(
249267
createdAt: new Date(nowMs),
250268
name: input.agentName,
251269
parentConversationId: input.parentConversationId,
252-
reasoningLevel: input.reasoningLevel ?? null,
270+
reasoningLevel: effectiveInput.reasoningLevel ?? null,
253271
updatedAt: new Date(nowMs),
254272
})
255273
.onConflictDoNothing();
@@ -262,7 +280,7 @@ export async function createAgentInvocation(
262280
`Named agent binding did not resolve to ${childConversationId}`,
263281
);
264282
}
265-
if (binding.reasoningLevel !== input.reasoningLevel) {
283+
if (binding.reasoningLevel !== effectiveInput.reasoningLevel) {
266284
throw new Error(
267285
`Named agent binding policy changed for ${input.agentName}`,
268286
);
@@ -283,13 +301,13 @@ export async function createAgentInvocation(
283301
parentConversationId: input.parentConversationId,
284302
childConversationId,
285303
agentName: input.agentName ?? null,
286-
input: input.input,
287-
actor: input.actor,
288-
credentialContext: input.credentialContext ?? null,
289-
source: input.source,
290-
destination: input.destination,
291-
destinationVisibility: input.destinationVisibility ?? null,
292-
reasoningLevel: input.reasoningLevel ?? null,
304+
input: effectiveInput.input,
305+
actor: effectiveInput.actor,
306+
credentialContext: effectiveInput.credentialContext ?? null,
307+
source: effectiveInput.source,
308+
destination: effectiveInput.destination,
309+
destinationVisibility: effectiveInput.destinationVisibility ?? null,
310+
reasoningLevel: effectiveInput.reasoningLevel ?? null,
293311
status: "pending",
294312
mailboxStatus: "pending",
295313
createdAt: new Date(nowMs),
@@ -298,7 +316,7 @@ export async function createAgentInvocation(
298316
})
299317
.onConflictDoNothing();
300318
const invocation = await getAgentInvocation(invocationId);
301-
if (!invocation || !sameCreateInput(invocation, input)) {
319+
if (!invocation || !sameCreateInput(invocation, effectiveInput)) {
302320
throw new Error(`Agent invocation creation raced for ${invocationId}`);
303321
}
304322
return { invocation, status: "created" };

packages/junior/src/chat/conversations/sql/purge.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import {
55
juniorConversationEvents,
66
juniorConversations,
77
juniorDestinations,
8+
juniorAgentBindings,
89
juniorAgentInvocations,
910
} from "@/db/schema";
1011
import { withConversationEventLock } from "./event-lock";
@@ -111,6 +112,11 @@ export async function selectExpiredRoots(
111112
where invocations.parent_conversation_id = tree.conversation_id
112113
or invocations.child_conversation_id = tree.conversation_id
113114
)
115+
or exists (
116+
select 1 from junior_agent_bindings bindings
117+
where bindings.parent_conversation_id = tree.conversation_id
118+
or bindings.child_conversation_id = tree.conversation_id
119+
)
114120
or (
115121
${juniorDestinations.visibility} is distinct from 'public'
116122
and exists (
@@ -274,6 +280,15 @@ export async function purgeConversationTree(
274280
inArray(juniorAgentInvocations.childConversationId, ids),
275281
),
276282
);
283+
await executor
284+
.db()
285+
.delete(juniorAgentBindings)
286+
.where(
287+
or(
288+
inArray(juniorAgentBindings.parentConversationId, ids),
289+
inArray(juniorAgentBindings.childConversationId, ids),
290+
),
291+
);
277292
await executor
278293
.db()
279294
.update(juniorConversations)

packages/junior/tests/component/conversations/retention.test.ts

Lines changed: 51 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -14,6 +14,7 @@ import {
1414
juniorConversationEvents,
1515
juniorConversations,
1616
juniorDestinations,
17+
juniorAgentBindings,
1718
juniorAgentInvocations,
1819
} from "@/db/schema";
1920
import type { JuniorDestinationVisibility } from "@/db/schema/destinations";
@@ -411,6 +412,56 @@ describe("retention purge job", () => {
411412
expect(await eventCount(fixture.sql, "remaining-child")).toBe(0);
412413
});
413414

415+
it("selects and purges a tree whose remaining content is only an agent binding", async () => {
416+
const dest = await seedDestination(fixture.sql, "private");
417+
await seedConversation(fixture.sql, {
418+
conversationId: "binding-root",
419+
destinationId: dest,
420+
lastActivityAtMs: BASE_MS,
421+
title: null,
422+
channelName: null,
423+
withContent: false,
424+
});
425+
await fixture.sql
426+
.db()
427+
.update(juniorConversations)
428+
.set({ actor: null })
429+
.where(eq(juniorConversations.conversationId, "binding-root"));
430+
await seedConversation(fixture.sql, {
431+
conversationId: "binding-child",
432+
parentConversationId: "binding-root",
433+
lastActivityAtMs: BASE_MS,
434+
title: null,
435+
channelName: null,
436+
withContent: false,
437+
});
438+
await fixture.sql
439+
.db()
440+
.update(juniorConversations)
441+
.set({ actor: null })
442+
.where(eq(juniorConversations.conversationId, "binding-child"));
443+
await fixture.sql
444+
.db()
445+
.insert(juniorAgentBindings)
446+
.values({
447+
childConversationId: "binding-child",
448+
createdAt: new Date(BASE_MS),
449+
name: "retained-name",
450+
parentConversationId: "binding-root",
451+
reasoningLevel: "high",
452+
updatedAt: new Date(BASE_MS),
453+
});
454+
455+
const result = await runRetentionPurge(fixture.sql, {
456+
nowMs: BASE_MS + 30 * DAY_MS,
457+
});
458+
459+
expect(result.purged).toBe(1);
460+
await expect(
461+
fixture.sql.db().select().from(juniorAgentBindings),
462+
).resolves.toEqual([]);
463+
});
464+
414465
it("purges up to the batch limit and leaves the remainder for the next run", async () => {
415466
const dest = await seedDestination(fixture.sql, "private");
416467
await seedConversation(fixture.sql, {

packages/junior/tests/integration/agent-invocation-concurrency.test.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -221,6 +221,7 @@ describe("agent invocation identity and concurrency", () => {
221221
agentName: "researcher",
222222
idempotencyKey: "named-1",
223223
input: "first named task",
224+
reasoningLevel: "high",
224225
});
225226
await harness.drainQueuedWork();
226227
const second = await harness.spawn({
@@ -235,6 +236,8 @@ describe("agent invocation identity and concurrency", () => {
235236
);
236237
expect(requests).toHaveLength(2);
237238
expect(requests[0]?.input.piMessages).toEqual([]);
239+
expect(requests[0]?.policy?.reasoningLevel).toBe("high");
240+
expect(requests[1]?.policy?.reasoningLevel).toBe("high");
238241
expect(requests[1]?.input.piMessages).toEqual(
239242
expect.arrayContaining([
240243
expect.objectContaining({ role: "user" }),
@@ -247,6 +250,7 @@ describe("agent invocation identity and concurrency", () => {
247250
await expect(
248251
getAgentInvocation(second.invocation.invocationId),
249252
).resolves.toMatchObject({
253+
reasoningLevel: "high",
250254
result: "result:second named task",
251255
status: "completed",
252256
});

packages/junior/tests/integration/agent-invocation-work.test.ts

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -163,6 +163,14 @@ describe("agent invocation conversation work", () => {
163163
agentName: "researcher",
164164
}),
165165
).rejects.toThrow("idempotency key was reused with different input");
166+
await expect(
167+
createAgentInvocation({
168+
...invocationInput,
169+
agentName: "researcher",
170+
idempotencyKey: "named-policy-change",
171+
reasoningLevel: "high",
172+
}),
173+
).rejects.toThrow("Named agent binding policy changed for researcher");
166174
await expect(
167175
createAgentInvocation({
168176
...invocationInput,

0 commit comments

Comments
 (0)