Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
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
31 changes: 31 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,37 @@ LLM_MODEL=qwen-plus
LLM_BASE_URL=https://dashscope.aliyuncs.com/compatible-mode/v1
LLM_API_KEY=

# Optional Agent runtime limit overrides. Defaults and safe ranges are defined in
# packages/agent-runtime/src/config/agent-runtime-limits.ts.
# DATAFOUNDRY_AGENT_MAX_STEPS=80
# DATAFOUNDRY_SQL_MAX_EXECUTION_COUNT=60
# DATAFOUNDRY_DATA_ANALYSIS_MAX_PROTOCOL_ACTIONS=500
# DATAFOUNDRY_GENERAL_TASK_MAX_PROTOCOL_ACTIONS=100
# DATAFOUNDRY_PROTOCOL_DEFAULT_MAX_ACTIONS=100
# DATAFOUNDRY_PROTOCOL_MAX_COMPLETION_REJECTIONS=3
# DATAFOUNDRY_PROTOCOL_MAX_COMMIT_RETRIES=8
# DATAFOUNDRY_PROTOCOL_AUTOMATIC_ACTION_MAX_DEPTH=10
# DATAFOUNDRY_SCHEMA_MAX_TABLES=20
# DATAFOUNDRY_SCHEMA_MAX_COLUMNS_PER_TABLE=50
# DATAFOUNDRY_SQL_MAX_MODEL_ROWS=20
# DATAFOUNDRY_SQL_MAX_ACTIVITY_ROWS=20
# DATAFOUNDRY_SQL_MAX_CELL_CHARS=500
# DATAFOUNDRY_SQL_MAX_SQL_CHARS=4000
# DATAFOUNDRY_CONTEXT_MAX_TOKENS=32000
# DATAFOUNDRY_CONTEXT_MAX_CHARS=32000
# DATAFOUNDRY_TOOL_ERROR_MAX_MESSAGE_CHARS=500
# DATAFOUNDRY_KNOWLEDGE_MAX_TOP_K=20
# DATAFOUNDRY_REQUIREMENT_COMMIT_MAX_CLAIMS=16
# DATAFOUNDRY_REQUIREMENT_COMMIT_MAX_OUTPUT_FIELDS=32
# DATAFOUNDRY_MODEL_HELPER_MAX_STEPS=1
# DATAFOUNDRY_PROTOCOL_CLASSIFIER_MAX_OUTPUT_TOKENS=512
# DATAFOUNDRY_REQUIREMENT_EXTRACTOR_MAX_OUTPUT_TOKENS=4096
# DATAFOUNDRY_CONTRACT_GROUNDER_MAX_OUTPUT_TOKENS=8192
# DATAFOUNDRY_CONTRACT_GROUNDER_MAX_ATTEMPTS=2
# DATAFOUNDRY_TOOL_OBSERVATION_MAX_NAMES=5
# DATAFOUNDRY_TOOL_OBSERVATION_MAX_NAME_CHARS=120
# DATAFOUNDRY_TOOL_OBSERVATION_MAX_CHARS=12000

EMBEDDING_PROVIDER=bailian
EMBEDDING_MODEL=text-embedding-v4
EMBEDDING_DIM=1024
Expand Down
1 change: 1 addition & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ site/
.next/
**/.next/
coverage/
artifacts/
storage/
**/storage/
.cache/
Expand Down
35 changes: 35 additions & 0 deletions apps/api/src/protocol-event-recovery.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
import { describe, expect, it } from "vitest";

import { replayPendingProtocolEvents } from "./protocol-event-recovery.js";

describe("replayPendingProtocolEvents", () => {
it("acknowledges a journal event only after delivery succeeds", () => {
const event = {
eventId: "event-1",
type: "protocol.state.updated",
runId: "run-1",
segmentId: "run-1:segment:1",
protocolId: "general-task",
protocolVersion: "1",
revision: 2
};
const delivered: unknown[] = [];
const acknowledged: unknown[] = [];

replayPendingProtocolEvents({
runId: "run-1",
stateStore: {
pendingEvents: () => [event],
acknowledgeEvent: (pending) => acknowledged.push(pending)
},
emit: (pending) => delivered.push(pending)
});

expect(delivered).toMatchObject([{
type: "CUSTOM",
name: "protocol.state.updated",
value: event
}]);
expect(acknowledged).toEqual([event]);
});
});
19 changes: 19 additions & 0 deletions apps/api/src/protocol-event-recovery.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,19 @@
import type { BaseEvent } from "@ag-ui/client";
import { createCustomEvent, type ProtocolEvent } from "@datafoundry/agent-runtime";

type ProtocolEventJournal = {
acknowledgeEvent(event: ProtocolEvent): void;
pendingEvents(runId: string): ProtocolEvent[];
};

/** Deliver durable protocol journal entries that were not published before a previous process stopped. */
export const replayPendingProtocolEvents = (input: {
runId: string;
stateStore: ProtocolEventJournal;
emit(event: BaseEvent): void;
}): void => {
for (const event of input.stateStore.pendingEvents(input.runId)) {
input.emit(createCustomEvent(event.type, event));
input.stateStore.acknowledgeEvent(event);
}
};
114 changes: 114 additions & 0 deletions apps/api/src/protocol-run-completion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,114 @@
import { EventType, type BaseEvent } from "@ag-ui/client";
import type { ProtocolRunState } from "@datafoundry/agent-runtime";
import { describe, expect, it, vi } from "vitest";

import { assistantMessageIdFromEvent, completeProtocolRun } from "./protocol-run-completion.js";

const terminalEvent = { type: EventType.RUN_FINISHED, timestamp: 1 } as BaseEvent;

describe("completeProtocolRun", () => {
it("recognizes a tool-only assistant turn through its parent message id", () => {
expect(assistantMessageIdFromEvent({
type: EventType.TOOL_CALL_START,
toolCallId: "tool-1",
toolCallName: "write_file",
parentMessageId: "tool-parent-message"
} as BaseEvent)).toBe("tool-parent-message");
});

it("uses the latest persisted assistant message when the current segment has no text event", async () => {
const harness = createHarness({});

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

expect(harness.execute).toHaveBeenCalledWith(expect.objectContaining({
actionName: "general.answer.commit",
input: { messageId: "persisted-message" }
}));
expect(harness.complete).toHaveBeenCalledOnce();
});

it("does not submit a second answer after the protocol has entered the answer phase", async () => {
const harness = createHarness({ answerMessageId: "committed-message", phase: "answer" });

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

expect(harness.execute).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"));

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

expect(harness.fail).toHaveBeenCalledWith({
errorMessage: "ACTION_NOT_ALLOWED_IN_PHASE:answer:general.answer.commit",
terminalEvent: expect.objectContaining({
type: EventType.RUN_ERROR,
message: "ACTION_NOT_ALLOWED_IN_PHASE:answer:general.answer.commit"
})
});
});
});

const createHarness = (input: { answerMessageId?: string; phase?: string }) => {
const execute = vi.fn(async () => undefined);
const complete = vi.fn(async () => undefined);
const fail = vi.fn();
let state: ProtocolRunState = {
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: [],
completionRejections: 0,
domain: input.answerMessageId ? { answerMessageId: input.answerMessageId } : {},
};
const protocolRuntime = {
getState: vi.fn(() => state),
proposeCompletion: vi.fn(() => {
state = {
...state,
revision: state.revision + 1,
terminalDecision: {
status: "completed",
evaluatedContextPackageRef: { packageId: "context-1", revision: 1 },
evidenceRefs: []
}
};
return state;
})
};
execute.mockImplementation(async () => {
state = { ...state, phase: "answer", domain: { answerMessageId: "persisted-message" } };
return undefined;
});
return {
complete,
execute,
fail,
input: {
finalizer: { complete, fail },
protocol: { actionRouter: { execute }, protocolRuntime, segmentId: "segment-1" },
runId: "run-1"
}
};
};
120 changes: 120 additions & 0 deletions apps/api/src/protocol-run-completion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,120 @@
import { EventType, type BaseEvent } from "@ag-ui/client";
import type { ProtocolRunState } from "@datafoundry/agent-runtime";

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

type ProtocolCompletionInput = {
finalizer: Pick<RunFinalizer, "complete" | "fail">;
goalRuntime?: Parameters<RunFinalizer["complete"]>[0]["goalRuntime"];
lastAssistantMessageId?: string;
persistedAssistantMessageId?: string;
protocol: {
actionRouter: {
execute(input: {
runId: string;
segmentId: string;
actionId: string;
actionName: string;
input: unknown;
idempotencyKey?: string;
}): Promise<unknown>;
};
protocolRuntime: {
getState(runId: string, segmentId?: string): ProtocolRunState;
proposeCompletion(input: {
runId: string;
segmentId: string;
expectedRevision: number;
forceTerminal?: boolean;
}): ProtocolRunState;
};
segmentId: string;
};
runId: string;
terminalEvent: BaseEvent;
};

/** Complete a governed protocol run and convert finalization failures into a durable RUN_ERROR. */
export const completeProtocolRun = async (input: ProtocolCompletionInput): Promise<void> => {
try {
let protocolState = input.protocol.protocolRuntime.getState(input.runId, input.protocol.segmentId);
const answerMessageId = input.lastAssistantMessageId ?? input.persistedAssistantMessageId;
if (
protocolState.protocolId === "general-task"
&& answerMessageId
&& !committedGeneralAnswerMessageId(protocolState)
) {
await input.protocol.actionRouter.execute({
runId: input.runId,
segmentId: input.protocol.segmentId,
actionId: `${input.runId}:general-answer-commit`,
actionName: "general.answer.commit",
input: { messageId: answerMessageId },
idempotencyKey: answerMessageId
});
protocolState = input.protocol.protocolRuntime.getState(input.runId, input.protocol.segmentId);
}
const terminalState = input.protocol.protocolRuntime.proposeCompletion({
runId: input.runId,
segmentId: input.protocol.segmentId,
expectedRevision: protocolState.revision,
forceTerminal: true
});
const terminalDecision = terminalState.terminalDecision;
if (!terminalDecision) {
throw new Error("PROTOCOL_TERMINAL_DECISION_REQUIRED");
}
if (terminalDecision.status === "failed") {
input.finalizer.fail({
errorMessage: terminalDecision.reasons.join("; "),
terminalEvent: createRunErrorEvent(terminalDecision.reasons.join("; "))
});
return;
}
await input.finalizer.complete({
...(input.goalRuntime ? { goalRuntime: input.goalRuntime } : {}),
terminalDecision,
terminalEvent: input.terminalEvent
});
} catch (error) {
const message = error instanceof Error ? error.message : "PROTOCOL_FINALIZATION_FAILED";
input.finalizer.fail({ errorMessage: message, terminalEvent: createRunErrorEvent(message) });
}
};

/** Return the assistant message identifier carried by text or tool-parent AG-UI events. */
export const assistantMessageIdFromEvent = (event: BaseEvent): string | undefined => {
if (event.type === EventType.TOOL_CALL_START) {
const parentMessageId = (event as BaseEvent & { parentMessageId?: string }).parentMessageId;
return typeof parentMessageId === "string" && parentMessageId.length > 0 ? parentMessageId : undefined;
}
if (
event.type !== EventType.TEXT_MESSAGE_START
&& event.type !== EventType.TEXT_MESSAGE_CONTENT
&& event.type !== EventType.TEXT_MESSAGE_CHUNK
&& event.type !== EventType.TEXT_MESSAGE_END
) {
return undefined;
}
const candidate = event as BaseEvent & { messageId?: string; role?: string };
if (candidate.role && candidate.role !== "assistant") {
return undefined;
}
return typeof candidate.messageId === "string" && candidate.messageId.length > 0
? candidate.messageId
: undefined;
};

const committedGeneralAnswerMessageId = (state: ProtocolRunState): string | undefined => {
if (typeof state.domain !== "object" || state.domain === null || Array.isArray(state.domain)) {
return undefined;
}
const messageId = (state.domain as Record<string, unknown>).answerMessageId;
return typeof messageId === "string" && messageId.length > 0 ? messageId : undefined;
};

const createRunErrorEvent = (message: string): BaseEvent => ({
type: EventType.RUN_ERROR,
message,
timestamp: Date.now()
});
Loading