Skip to content
Merged
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
67 changes: 62 additions & 5 deletions apps/api/src/config-api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import {
import { buildSessionTraceDag } from "./trace-dag.js";
import { readMultipartFiles, readMultipartUpload } from "./upload-parser.js";
import { knowledgeDocumentTextFromFile } from "./knowledge-document-text.js";
import { resolveLiveSessionActiveRun } from "./stale-active-runs.js";

const MAX_JSON_BODY_BYTES = 1024 * 1024;
const DEFAULT_WORKSPACE_ID = "default";
Expand Down Expand Up @@ -445,7 +446,17 @@ const handleSessionRequest = async (
...(cursor ? { cursor } : {})
});
return ok({
sessions: records.map(sessionListDto),
sessions: records.map((session) =>
sessionListDto(
session,
resolveLiveSessionActiveRun({
metadataStore: context.metadataStore,
runCancelRegistry: context.runCancelRegistry,
userId: context.userId,
sessionId: session.id
})
)
),
...(records.length === limit ? { nextCursor: encodeSessionCursor(records.at(-1) as SessionRecord) } : {})
});
}
Expand All @@ -466,6 +477,20 @@ const handleSessionRequest = async (
});
return ok(sessionTitleDto(session));
}
if (!action && request.method === "DELETE") {
const result = context.metadataStore.sessions.delete({
user_id: context.userId,
session_id: sessionId
});
if (!result.deleted) {
throw new Error(`Session not found: ${sessionId}`);
}
return ok({
sessionId,
deleted: true,
deletedSessionIds: result.deletedSessionIds
});
}
if (action === "branches" && request.method === "POST") {
const body = await readJsonBody(request);
const runId = stringValue(body.runId ?? body.run_id);
Expand Down Expand Up @@ -594,6 +619,13 @@ const handleSessionRequest = async (
authoritativeToolNames
);

const activeRun = resolveLiveSessionActiveRun({
metadataStore: context.metadataStore,
runCancelRegistry: context.runCancelRegistry,
userId: context.userId,
sessionId
});

return ok({
sessionId,
title: session.title ?? "",
Expand All @@ -609,7 +641,8 @@ const handleSessionRequest = async (
pendingInteractions: pendingInteractions.map(pendingInteractionDto),
restorableCustomEvents: runEventGroups.flatMap(({ runId, events }) =>
restorableCustomEventDtos(runId, events)
)
),
...(activeRun ? { activeRun: sessionActiveRunDto(activeRun) } : {})
});
};

Expand Down Expand Up @@ -2383,21 +2416,45 @@ const conversationMessageEvidenceRefsDto = (message: ConversationMessageRecord):
return refs.length > 0 ? { evidenceRefs: refs } : {};
};

const sessionListDto = (session: SessionRecord): Record<string, unknown> => ({
const truncateUserInputPreview = (value: string, maxLength = 120): string => {
const normalized = value.replace(/\s+/g, " ").trim();
if (normalized.length <= maxLength) {
return normalized;
}
return `${normalized.slice(0, Math.max(0, maxLength - 1))}…`;
};

const sessionActiveRunDto = (run: RunRecord): Record<string, unknown> => ({
sessionId: run.session_id,
activeRunId: run.id,
status: run.status,
startedAt: run.started_at,
userInputPreview: truncateUserInputPreview(run.user_input)
});

const sessionListDto = (
session: SessionRecord,
activeRun?: RunRecord | null
): Record<string, unknown> => ({
id: session.id,
threadId: session.id,
title: session.title ?? "",
titleSource: session.title_source ?? "fallback",
createdAt: session.created_at,
updatedAt: session.updated_at,
lastMessageAt: session.last_message_at ?? session.updated_at
lastMessageAt: session.last_message_at ?? session.updated_at,
...(activeRun ? { activeRun: sessionActiveRunDto(activeRun) } : {})
});

const sessionBranchCreatedDto = (
input: { branch: SessionBranchRecord; session: SessionRecord }
): Record<string, unknown> => ({
...sessionBranchDto(input.branch, input.session),
session: sessionListDto(input.session)
session: sessionListDto(
input.session,
// Branch create does not need active-run lookup; omit to keep response light.
null
)
});

const sessionBranchDto = (
Expand Down
10 changes: 9 additions & 1 deletion apps/api/src/evidence-reference-context.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,14 +96,22 @@ const artifactEvidenceItems = (
const maxChars = input.maxCharsPerEvidence ?? 6000;
const focus = resolveSelectionFocus(ref.source.selection, preview, maxChars);
const includeFullPreview = preview !== undefined && (!focus || !focus.replaceFullPreview);
// When a table subset is already inlined, omit file_id so the model is not nudged
// to open the full artifact file for a partial cite.
const fileIdLine = artifact.file_asset_ref_id && !focus?.replaceFullPreview
? `file_id=${artifact.file_asset_ref_id}`
: undefined;
const content = evidenceText({
body: [
`artifact_id=${artifact.id}`,
`artifact_type=${artifact.type}`,
`artifact_name=${artifact.name}`,
artifact.file_asset_ref_id ? `file_id=${artifact.file_asset_ref_id}` : undefined,
fileIdLine,
includeFullPreview ? `preview=${boundJson(preview, maxChars)}` : undefined,
metadata !== undefined ? `metadata=${boundJson(metadata, 1200)}` : undefined,
focus?.replaceFullPreview
? "note: selected subset is already inlined below; do not open the full artifact file unless the user asks for broader context"
: undefined,
...(focus?.lines ?? [])
],
ref
Expand Down
5 changes: 5 additions & 0 deletions apps/api/src/run-cancel-registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -41,6 +41,11 @@ export class RunCancelRegistry {
return { canceled: true, runId: handle.runId, sessionId: handle.sessionId };
}

/** True when this process still owns a live cancel handle for the run. */
has(input: { runId: string; userId: string }): boolean {
return this.handles.has(this.key(input.userId, input.runId));
}

private key(userId: string, runId: string): string {
return `${userId}:${runId}`;
}
Expand Down
13 changes: 9 additions & 4 deletions apps/api/src/run-identity-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
validateParentRun
} from "./run-identity.js";
import type { EffectiveRunConfig } from "./run-input.js";
import type { RunCancelRegistry } from "./run-cancel-registry.js";
import { resolveLiveSessionActiveRun } from "./stale-active-runs.js";

export type RunIdentityResolution =
| {
Expand All @@ -25,6 +27,7 @@ type ResolveRunIdentityInput = {
interactionResume?: InteractionResume | undefined;
metadataStore: MetadataStore;
modelName: string;
runCancelRegistry: RunCancelRegistry;
runEventWriter: RunEventWriter;
runInput: RunAgentInput;
userId: string;
Expand Down Expand Up @@ -68,10 +71,12 @@ export const resolveRunIdentity = (input: ResolveRunIdentityInput): RunIdentityR
};
}

const activeSessionRun = input.metadataStore.runs.findActiveBySession({
user_id: input.userId,
session_id: sessionId,
exclude_run_id: runId
const activeSessionRun = resolveLiveSessionActiveRun({
metadataStore: input.metadataStore,
runCancelRegistry: input.runCancelRegistry,
userId: input.userId,
sessionId,
excludeRunId: runId
});

if (activeSessionRun) {
Expand Down
49 changes: 47 additions & 2 deletions apps/api/src/run-input.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import type { RunAgentInput } from "@ag-ui/client";
import type { EvidenceKind, EvidenceRef } from "@datafoundry/contracts";
import type { EvidenceKind, EvidenceRef, EvidenceSelection } from "@datafoundry/contracts";
import type { ConfigResourceKind, MetadataStore } from "@datafoundry/metadata";
import type { SkillMode, SkillPolicyConfig } from "@datafoundry/skills";

Expand Down Expand Up @@ -546,6 +546,7 @@ const evidenceRefFromUnknown = (value: unknown): EvidenceRef | undefined => {

const evidenceRefSourceFromUnknown = (value: unknown): EvidenceRef["source"] => {
const source = isRecord(value) ? value : {};
const selection = evidenceSelectionFromUnknown(source.selection);
return {
...optionalStringField(source, "artifactId", "artifact_id"),
...optionalStringField(source, "toolCallId", "tool_call_id"),
Expand All @@ -555,10 +556,54 @@ const evidenceRefSourceFromUnknown = (value: unknown): EvidenceRef["source"] =>
...optionalStringField(source, "datasourceId", "datasource_id"),
...optionalStringField(source, "tableName", "table_name"),
...optionalStringField(source, "documentId", "document_id"),
...optionalStringField(source, "chunkId", "chunk_id")
...optionalStringField(source, "chunkId", "chunk_id"),
...(selection ? { selection } : {})
};
};

/** Parses fine-grained table/text selections so partial cites survive run_config intake. */
const evidenceSelectionFromUnknown = (value: unknown): EvidenceSelection | undefined => {
if (!isRecord(value) || typeof value.mode !== "string") {
return undefined;
}
if (value.mode === "text") {
const quote = typeof value.quote === "string" ? value.quote.trim() : "";
if (!quote) return undefined;
const offset = typeof value.offset === "number" && Number.isFinite(value.offset)
? Math.trunc(value.offset)
: undefined;
return offset === undefined ? { mode: "text", quote } : { mode: "text", quote, offset };
}
if (value.mode !== "cells" && value.mode !== "rows" && value.mode !== "cols") {
return undefined;
}
const range = evidenceCellRangeFromUnknown(value.range);
if (!range) return undefined;
const columns = Array.isArray(value.columns)
? value.columns.filter((column): column is string => typeof column === "string" && column.length > 0)
: undefined;
return columns && columns.length > 0
? { mode: value.mode, range, columns }
: { mode: value.mode, range };
};

const evidenceCellRangeFromUnknown = (
value: unknown
): { r0: number; c0: number; r1: number; c1: number } | undefined => {
if (!isRecord(value)) return undefined;
const r0 = finiteIndex(value.r0);
const c0 = finiteIndex(value.c0);
const r1 = finiteIndex(value.r1);
const c1 = finiteIndex(value.c1);
if (r0 === undefined || c0 === undefined || r1 === undefined || c1 === undefined) {
return undefined;
}
return { r0, c0, r1, c1 };
};

const finiteIndex = (value: unknown): number | undefined =>
typeof value === "number" && Number.isFinite(value) ? Math.trunc(value) : undefined;

const optionalStringField = (
record: Record<string, unknown>,
camelKey: keyof EvidenceRef["source"],
Expand Down
22 changes: 17 additions & 5 deletions apps/api/src/server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ import { Observable } from "rxjs";
import { handleConfigApiRequest } from "./config-api.js";
import { createAsyncMemoByKey, createStartupTimer } from "./async-memo.js";
import { ensureBuiltinDtcGrowthDatasource } from "./builtin-dtc-growth-datasource.js";
import { reclaimOrphanedQueuedAndRunningRuns } from "./stale-active-runs.js";
import { loadPasswordAuthConfig, type PasswordAuthConfig } from "./auth/config.js";
import { AuthService, type AuthIdentity } from "./auth/service.js";
import { serverDefaultConnectionStatus, isServerLlmEnvConfigured } from "./model-profile-connection-status.js";
Expand Down Expand Up @@ -220,6 +221,17 @@ export const createServer = async (options: CreateServerOptions = {}): Promise<S
),
]);

// After restart, cancel-registry is empty — reclaim queued/running rows left by dead workers.
const reclaimedActiveRuns = await timer.measure("stale_active_run_reclaim", () =>
reclaimOrphanedQueuedAndRunningRuns({
metadataStore,
runCancelRegistry,
}),
);
if (reclaimedActiveRuns > 0) {
console.log(`[startup] stale active run reclaim: canceled=${reclaimedActiveRuns}`);
}

startupTimings = timer.timings();
startupTotalMs = timer.totalMs();
serverReady = true;
Expand Down Expand Up @@ -527,6 +539,7 @@ class DataFoundryAgUiAgent extends AbstractAgent {
...(interactionResume ? { interactionResume } : {}),
metadataStore: this.input.metadataStore,
modelName: modelProvider.model_name,
runCancelRegistry: this.input.runCancelRegistry,
runEventWriter,
runInput: normalizedRunInput,
userId: this.input.user.id,
Expand Down Expand Up @@ -915,11 +928,10 @@ class DataFoundryAgUiAgent extends AbstractAgent {
if (!isResume && !sessionTitleStarted) {
sessionTitleStarted = true;
startSessionTitleTask({
emit: (titleEvent) => {
if (!terminalStarted) {
emit(titleEvent);
}
},
// Title generation is async and may finish after the agent run
// terminals; still forward the event while the stream is open
// (finalizer continues emitting after RUN_FINISHED).
emit,
metadataStore: this.input.metadataStore,
model: modelProvider.model,
modelTemperature: modelSettings?.temperature,
Expand Down
19 changes: 13 additions & 6 deletions apps/api/src/session-title.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,9 @@ import { createCustomEvent } from "@datafoundry/agent-runtime";
import type { MetadataStore, SessionRecord } from "@datafoundry/metadata";
import type { BaseEvent } from "@ag-ui/client";

const TITLE_TIMEOUT_MS = 5000;
/** Reasoning models (e.g. deepseek-v4-pro) spend output budget on thinking first. */
const TITLE_TIMEOUT_MS = 15_000;
const TITLE_MAX_OUTPUT_TOKENS = 256;
const TITLE_MAX_CHARS = 32;

export type SessionTitleTaskInput = {
Expand All @@ -26,17 +28,18 @@ const generateAndPersistSessionTitle = async (input: SessionTitleTaskInput): Pro
user_id: input.userId,
session_id: input.sessionId
});
if (current.title_source === "user" || (current.title && current.title.trim().length > 0)) {
// Only skip manual renames / already-finalized LLM titles. Empty or fallback
// titles (and legacy rows with a prefilled title but no source) may still be replaced.
if (current.title_source === "user" || current.title_source === "llm") {
return;
}

const title = await generateLlmTitle(input).catch(() => fallbackTitle(input.userInput));
const source: SessionRecord["title_source"] = title.source;
const updated = input.metadataStore.sessions.updateAutoTitleIfAllowed({
user_id: input.userId,
session_id: input.sessionId,
title: title.title,
title_source: source === "llm" ? "llm" : "fallback"
title_source: title.source === "llm" ? "llm" : "fallback"
});
if (!updated) {
return;
Expand All @@ -61,7 +64,7 @@ const generateLlmTitle = async (
defaultOptions: {
maxSteps: 1,
modelSettings: {
maxOutputTokens: 32,
maxOutputTokens: TITLE_MAX_OUTPUT_TOKENS,
temperature: input.modelTemperature ?? 0.2
},
providerOptions: {
Expand All @@ -74,7 +77,11 @@ const generateLlmTitle = async (
const output = await agent.generate(buildTitlePrompt(input.userInput), {
abortSignal: AbortSignal.timeout(TITLE_TIMEOUT_MS)
});
const title = sanitizeTitle(output.text) || fallbackTitle(input.userInput).title;
const title = sanitizeTitle(output.text);
if (!title) {
// Empty text usually means the output budget was spent on reasoning tokens.
throw new Error("SESSION_TITLE_EMPTY");
}
return { source: "llm", title };
};

Expand Down
Loading