Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
4205976
fix(agent-runtime): include allowed actions in phase rejection errors
PM-pinou Aug 7, 2026
63dd3c8
fix(agent-runtime): declare protocol tool restrictions in agent instr…
PM-pinou Aug 7, 2026
ab60a62
chore(ci): run package unit tests in CI
PM-pinou Aug 7, 2026
739f296
merge: fix/phase-rejection-guidance
PM-pinou Aug 7, 2026
f06c9ec
fix(api): stop finalizing runs as completed when the protocol rejecte…
PM-pinou Aug 7, 2026
41ef81e
test(api): regression for tool-rejected runs surfacing as failures
PM-pinou Aug 7, 2026
7000357
merge: fix/completion-hard-gate
PM-pinou Aug 7, 2026
72d91d3
feat(metadata): add session intent repository with branch-lineage res…
PM-pinou Aug 7, 2026
a95e5c1
feat(agent-runtime): inherit session intent as deterministic route ca…
PM-pinou Aug 7, 2026
0598817
refactor(agent-runtime): route before requirement extraction; drive e…
PM-pinou Aug 7, 2026
2c955aa
feat(api): persist and resolve session intent around agent runs
PM-pinou Aug 7, 2026
9bbb804
fix(agent-runtime): widen the analytic-intent accelerator and documen…
PM-pinou Aug 7, 2026
6de50c2
merge: fix/session-intent-routing
PM-pinou Aug 7, 2026
147c76d
refactor(agent-runtime): use the shared data-action constant in proto…
PM-pinou Aug 7, 2026
179e934
refactor(agent-runtime): extract protocol-bound tool execute helper
PM-pinou Aug 7, 2026
13be12b
refactor(agent-runtime): assemble run tools through a reasoned tool plan
PM-pinou Aug 7, 2026
30fd5dc
merge: refactor/tool-plan
PM-pinou Aug 7, 2026
d3ce73b
feat(agent-runtime): budgeted helper context builder
PM-pinou Aug 7, 2026
ed0ba80
feat(api): feed budgeted helper context to protocol classifier and se…
PM-pinou Aug 7, 2026
6cd9b74
merge: feat/helper-context
PM-pinou Aug 7, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,9 @@ jobs:
- name: Build TypeScript workspaces
run: npm run build

- name: Run package unit tests
run: npm run test:packages

- name: Run Web tests
run: npm run test:web

Expand Down
78 changes: 75 additions & 3 deletions apps/api/src/protocol-run-completion.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,65 @@ describe("completeProtocolRun", () => {
expect(harness.complete).toHaveBeenCalledOnce();
});

it("finalizes as failed when general-task rejected the run's data actions", async () => {
const harness = createHarness({
actions: [
rejectedAction("a1", "inspect_schema"),
rejectedAction("a2", "inspect_schema"),
rejectedAction("a3", "list_data_sources")
]
});

await completeProtocolRun({
...harness.input,
lastAssistantMessageId: "apology-message",
terminalEvent
});

expect(harness.execute).not.toHaveBeenCalled();
expect(harness.complete).not.toHaveBeenCalled();
expect(harness.input.protocol.protocolRuntime.proposeCompletion).toHaveBeenCalledWith(
expect.objectContaining({ forceTerminal: true })
);
expect(harness.fail).toHaveBeenCalledWith(expect.objectContaining({
errorMessage: expect.stringContaining("DATA_ACTIONS_REJECTED_BY_PROTOCOL"),
terminalEvent: expect.objectContaining({ type: EventType.RUN_ERROR })
}));
expect(harness.fail.mock.calls[0]?.[0]?.errorMessage).toContain("inspect_schema, list_data_sources");
});

it("keeps completing general-task runs whose rejected actions are not data actions", async () => {
const harness = createHarness({
actions: [rejectedAction("a1", "retrieve_knowledge")]
});

await completeProtocolRun({
...harness.input,
lastAssistantMessageId: "message-1",
terminalEvent
});

expect(harness.fail).not.toHaveBeenCalled();
expect(harness.complete).toHaveBeenCalledOnce();
});

it("does not gate data-analysis runs on phase-rejected data actions", async () => {
const harness = createHarness({
protocolId: "data-analysis",
answerMessageId: "n/a",
actions: [rejectedAction("a1", "run_sql_readonly")]
});

await completeProtocolRun({
...harness.input,
lastAssistantMessageId: "message-1",
terminalEvent
});

expect(harness.fail).not.toHaveBeenCalled();
expect(harness.complete).toHaveBeenCalledOnce();
});

it("emits a clean run error when terminal protocol finalization fails", async () => {
const harness = createHarness({});
harness.execute.mockRejectedValueOnce(new Error("ACTION_NOT_ALLOWED_IN_PHASE:answer:general.answer.commit"));
Expand All @@ -65,20 +124,33 @@ describe("completeProtocolRun", () => {
});
});

const createHarness = (input: { answerMessageId?: string; phase?: string }) => {
const rejectedAction = (actionId: string, actionName: string): ProtocolRunState["actions"][number] => ({
actionId,
actionName,
status: "rejected",
inputContextPackageRef: { packageId: "context-1", revision: 1 },
reasonCode: "ACTION_NOT_ALLOWED_IN_PHASE"
});

const createHarness = (input: {
answerMessageId?: string;
phase?: string;
protocolId?: string;
actions?: ProtocolRunState["actions"];
}) => {
const execute = vi.fn(async () => undefined);
const complete = vi.fn(async () => undefined);
const fail = vi.fn();
let state: ProtocolRunState = {
protocolId: "general-task",
protocolId: input.protocolId ?? "general-task",
protocolVersion: "1",
runId: "run-1",
segmentId: "segment-1",
phase: input.phase ?? "gather",
revision: 1,
status: "active",
contextPackageRef: { packageId: "context-1", revision: 1 },
actions: [],
actions: input.actions ?? [],
completionRejections: 0,
domain: input.answerMessageId ? { answerMessageId: input.answerMessageId } : {},
};
Expand Down
23 changes: 22 additions & 1 deletion apps/api/src/protocol-run-completion.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import { EventType, type BaseEvent } from "@ag-ui/client";
import type { ProtocolRunState } from "@datafoundry/agent-runtime";
import { isDataActionName, type ProtocolRunState } from "@datafoundry/agent-runtime";

import type { RunFinalizer } from "./run-finalizer.js";

Expand Down Expand Up @@ -38,6 +38,27 @@ type ProtocolCompletionInput = {
export const completeProtocolRun = async (input: ProtocolCompletionInput): Promise<void> => {
try {
let protocolState = input.protocol.protocolRuntime.getState(input.runId, input.protocol.segmentId);
const rejectedDataActions = protocolState.actions.filter((action) =>
action.status === "rejected"
&& isDataActionName(action.actionName)
&& (action.reasonCode?.startsWith("ACTION_NOT_ALLOWED_IN_PHASE") ?? false));
if (protocolState.protocolId === "general-task" && rejectedDataActions.length > 0) {
// The agent tried to do data work and the protocol refused every attempt. A
// closing text message must not launder that into "completed": record a terminal
// protocol decision for replay, then surface the run as failed with the reason.
input.protocol.protocolRuntime.proposeCompletion({
runId: input.runId,
segmentId: input.protocol.segmentId,
expectedRevision: protocolState.revision,
forceTerminal: true
});
const attempted = [...new Set(rejectedDataActions.map((action) => action.actionName))].join(", ");
const message = `DATA_ACTIONS_REJECTED_BY_PROTOCOL: ${rejectedDataActions.length} data tool call(s) `
+ `(${attempted}) were rejected by the general-task protocol before execution, so the requested analysis `
+ "never ran. The final assistant text explains the failure and is not a completed analysis.";
input.finalizer.fail({ errorMessage: message, terminalEvent: createRunErrorEvent(message) });
return;
}
const answerMessageId = input.lastAssistantMessageId ?? input.persistedAssistantMessageId;
if (
protocolState.protocolId === "general-task"
Expand Down
8 changes: 8 additions & 0 deletions apps/api/src/run-agent-assembly.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ import {
type RunProtocolBoundary,
type ContextPackageRef,
type ProtocolStateStore,
type SessionIntent,
type TaskStateRuntime,
type WorkspaceAttachment
} from "@datafoundry/agent-runtime";
Expand Down Expand Up @@ -79,6 +80,11 @@ type CreateRunAgentAssemblyInput = {
runContext: AgentRunContext;
sessionOutputService: SessionOutputService;
selectedSkills: SkillRecord[];
/** Session intent resolved by the caller; enables deterministic protocol
* inheritance for weak follow-ups. */
sessionIntent?: SessionIntent | undefined;
/** Budgeted background block for the protocol classifier. */
classifierContext?: string | undefined;
skillSelection: SkillSelectionResult;
taskStateRuntime: TaskStateRuntime;
userId: string;
Expand Down Expand Up @@ -172,6 +178,8 @@ export const createRunAgentAssembly = async (
runContext: input.runContext,
sessionOutputService: input.sessionOutputService,
selectedSkills: input.selectedSkills,
...(input.sessionIntent ? { sessionIntent: input.sessionIntent } : {}),
...(input.classifierContext ? { classifierContext: input.classifierContext } : {}),
skillSelection: input.skillSelection,
taskStateRuntime: input.taskStateRuntime,
...(!input.interactionResume && input.goal ? { goal: input.goal } : {}),
Expand Down
36 changes: 35 additions & 1 deletion apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,7 +52,9 @@ import {
} from "./auth/routes.js";
import { createMetadataContextPackageRecorder } from "./context-package-recorder.js";
import { MetadataProtocolStateStore } from "./protocol-state-store.js";
import { buildHelperContext } from "@datafoundry/agent-runtime";
import { replayPendingProtocolEvents } from "./protocol-event-recovery.js";
import { persistSessionIntentFromRoute, resolveSessionIntentForRun } from "./session-intent.js";
import { assistantMessageIdFromEvent, completeProtocolRun } from "./protocol-run-completion.js";
import { persistCurrentUserMessage } from "./conversation-memory.js";
import { resolveEvidenceReferenceContext } from "./evidence-reference-context.js";
Expand Down Expand Up @@ -643,6 +645,21 @@ class DataFoundryAgUiAgent extends AbstractAgent {
eventPipeline.emit(event);
};
replayPendingProtocolEvents({ runId, stateStore: protocolStateStore, emit });
const sessionIntent = resolveSessionIntentForRun({
metadataStore: this.input.metadataStore,
userId: this.input.user.id,
sessionId
});
const classifierContext = buildHelperContext({
...(sessionIntent
? { sessionIntent: { protocolId: sessionIntent.protocolId, intentText: sessionIntent.intentText } }
: {}),
conversationSummary: this.input.metadataStore.conversationSummaries.latest({
user_id: this.input.user.id,
session_id: sessionId
})?.summary_text,
relevantMemories: longTermMemories.map((memory) => memory.content_text)
});
const agentAssembly = await createRunAgentAssembly({
abortSignal: runAbortController.signal,
contextPackageRecorder,
Expand Down Expand Up @@ -674,11 +691,21 @@ class DataFoundryAgUiAgent extends AbstractAgent {
runContext,
selectedSkills,
skillSelection,
...(sessionIntent ? { sessionIntent } : {}),
...(classifierContext ? { classifierContext: classifierContext.text } : {}),
taskStateRuntime: this.input.taskStateRuntime,
userId: this.input.user.id,
workspaceId: this.input.workspaceId,
workspaceRoot: this.input.workspaceRoot
});
persistSessionIntentFromRoute({
metadataStore: this.input.metadataStore,
userId: this.input.user.id,
sessionId,
runId,
userInput,
route: agentAssembly.protocol.route
});
const finalizer = new RunFinalizer({
destroyWorkspace: agentAssembly.destroyWorkspace,
emit,
Expand Down Expand Up @@ -979,7 +1006,14 @@ class DataFoundryAgUiAgent extends AbstractAgent {
modelTemperature: modelSettings?.temperature,
sessionId,
userId: this.input.user.id,
userInput
// Title the session by its recorded task, not by a weak follow-up:
// a branched session whose first message is "再次尝试" should carry
// its inherited intent as the title.
userInput: resolveSessionIntentForRun({
metadataStore: this.input.metadataStore,
userId: this.input.user.id,
sessionId
})?.intentText ?? userInput
});
}
}
Expand Down
93 changes: 93 additions & 0 deletions apps/api/src/session-intent.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,93 @@
import { mkdtempSync, rmSync } from "node:fs";
import { tmpdir } from "node:os";
import { join } from "node:path";
import { afterEach, beforeEach, describe, expect, it } from "vitest";

import { createMetadataStore, createVerifiedTestIdentity, type MetadataStore } from "@datafoundry/metadata";

import { persistSessionIntentFromRoute, resolveSessionIntentForRun } from "./session-intent.js";

describe("session intent run wiring", () => {
let root: string;
let metadata: MetadataStore;
let userId: string;

beforeEach(() => {
root = mkdtempSync(join(tmpdir(), "session-intent-wiring-"));
metadata = createMetadataStore({ database_path: join(root, "metadata.sqlite") });
userId = createVerifiedTestIdentity(metadata).userId;
metadata.sessions.create({ user_id: userId, id: "session-1", title: "t" });
metadata.runs.create({
user_id: userId, id: "run-1", session_id: "session-1", user_input: "帮我分析当前数据", status: "running"
});
});

afterEach(() => {
rmSync(root, { recursive: true, force: true });
});

const persist = (input: {
source: string;
reasonCodes?: string[];
userInput?: string;
protocolId?: string;
}): boolean => persistSessionIntentFromRoute({
metadataStore: metadata,
userId,
sessionId: "session-1",
runId: "run-1",
userInput: input.userInput ?? "帮我分析当前数据",
route: {
definition: { id: input.protocolId ?? "data-analysis", version: "1" },
reasonCodes: input.reasonCodes ?? [],
source: input.source
}
});

it.each([
["explicit", []],
["classifier", ["FOLLOW_UP"]],
["deterministic", ["ANALYTIC_INTENT"]]
])("persists the intent for a %s route", (source, reasonCodes) => {
expect(persist({ source, reasonCodes: reasonCodes as string[] })).toBe(true);
expect(resolveSessionIntentForRun({ metadataStore: metadata, userId, sessionId: "session-1" }))
.toEqual({ protocolId: "data-analysis", protocolVersion: "1", intentText: "帮我分析当前数据" });
});

it.each([
["default", []],
["deterministic", ["SESSION_INTENT_INHERITED"]],
["deterministic", ["PROTOCOL_SEGMENT_RESTORED"]]
])("does not overwrite the intent for a %s route with %j", (source, reasonCodes) => {
expect(persist({ source: "deterministic", reasonCodes: ["ANALYTIC_INTENT"] })).toBe(true);

expect(persist({
source,
reasonCodes: reasonCodes as string[],
userInput: "再次尝试",
protocolId: "general-task"
})).toBe(false);

expect(resolveSessionIntentForRun({ metadataStore: metadata, userId, sessionId: "session-1" }))
.toEqual({ protocolId: "data-analysis", protocolVersion: "1", intentText: "帮我分析当前数据" });
});

it("skips persistence for empty user input", () => {
expect(persist({ source: "classifier", userInput: " " })).toBe(false);
expect(resolveSessionIntentForRun({ metadataStore: metadata, userId, sessionId: "session-1" }))
.toBeUndefined();
});

it("resolves the intent for a branched session through its lineage", () => {
expect(persist({ source: "deterministic", reasonCodes: ["ANALYTIC_INTENT"] })).toBe(true);
metadata.sessions.create({ user_id: userId, id: "session-branch", title: "b" });
metadata.sessionBranches.create({
user_id: userId, id: "branch:session-branch", child_session_id: "session-branch",
parent_session_id: "session-1", root_session_id: "session-1",
fork_run_id: "run-1", fork_message_end_position: 1
});

expect(resolveSessionIntentForRun({ metadataStore: metadata, userId, sessionId: "session-branch" }))
.toEqual({ protocolId: "data-analysis", protocolVersion: "1", intentText: "帮我分析当前数据" });
});
});
Loading