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
75 changes: 75 additions & 0 deletions src/agent-harness/runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
buildHarnessMessages,
buildHarnessSystemPrompt,
createHarnessTools,
sanitizeHarnessModelMessagesForStep,
} from "./runtime"
import { createEvidenceLedger } from "./ledger"
import type {
Expand Down Expand Up @@ -251,6 +252,80 @@ describe("agent harness runtime", () => {
expect(JSON.stringify(messages)).toContain("id=turn_1 role=assistant")
expect(JSON.stringify(messages)).not.toContain("searchSources.query")
})

it("removes provider metadata from tool-result parts while preserving tool-call metadata", () => {
const messages = sanitizeHarnessModelMessagesForStep([
{
role: "assistant",
content: [
{
type: "tool-call",
toolCallId: "call_1",
toolName: "retrieve",
input: { query: "q4" },
providerOptions: {
google: {
thoughtSignature: "signature-1",
},
},
},
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_1",
toolName: "retrieve",
output: {
type: "json",
value: {
ok: true,
},
},
providerOptions: {
google: {
thoughtSignature: "signature-1",
},
},
},
],
},
])

expect(messages).toEqual([
{
role: "assistant",
content: [
expect.objectContaining({
type: "tool-call",
providerOptions: {
google: {
thoughtSignature: "signature-1",
},
},
}),
],
},
{
role: "tool",
content: [
{
type: "tool-result",
toolCallId: "call_1",
toolName: "retrieve",
output: {
type: "json",
value: {
ok: true,
},
},
},
],
},
])
})
})

function executeTool(tool: unknown, input: unknown): Promise<unknown> {
Expand Down
67 changes: 66 additions & 1 deletion src/agent-harness/runtime.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,10 @@
import { stepCountIs, ToolLoopAgent, tool, type ModelMessage } from "ai"
import {
stepCountIs,
ToolLoopAgent,
tool,
type ModelMessage,
type ToolResultPart,
} from "ai"
import { z } from "zod"

import { createEvidenceLedger } from "./ledger"
Expand Down Expand Up @@ -147,6 +153,9 @@ export async function runAgentHarness(
model: input.model,
instructions: buildHarnessSystemPrompt(input.turn),
tools,
prepareStep: ({ messages: stepMessages }) => ({
messages: sanitizeHarnessModelMessagesForStep(stepMessages),
}),
stopWhen: stepCountIs(input.maxSteps ?? defaultMaxSteps),
})

Expand Down Expand Up @@ -205,6 +214,62 @@ export async function runAgentHarness(
}
}

export function sanitizeHarnessModelMessagesForStep(
messages: readonly ModelMessage[],
): ModelMessage[] {
return messages.map(sanitizeHarnessModelMessageForStep)
}

function sanitizeHarnessModelMessageForStep(
message: ModelMessage,
): ModelMessage {
if (message.role === "tool") {
return {
...message,
providerOptions: undefined,
content: message.content.map(sanitizeToolMessageContentPart),
}
}

if (message.role === "assistant" && Array.isArray(message.content)) {
return {
...message,
content: message.content.map(sanitizeAssistantMessageContentPart),
}
}

return message
}

function sanitizeToolMessageContentPart(
part: ModelMessageForRole<"tool">["content"][number],
): ModelMessageForRole<"tool">["content"][number] {
if (part.type !== "tool-result") return part

return removeToolResultPartProviderOptions(part)
}

function sanitizeAssistantMessageContentPart(
part: Exclude<ModelMessageForRole<"assistant">["content"], string>[number],
): Exclude<ModelMessageForRole<"assistant">["content"], string>[number] {
if (part.type !== "tool-result") return part

return removeToolResultPartProviderOptions(part)
}

function removeToolResultPartProviderOptions(
part: ToolResultPart,
): ToolResultPart {
const sanitizedPart = { ...part }
delete sanitizedPart.providerOptions
return sanitizedPart
}

type ModelMessageForRole<TRole extends ModelMessage["role"]> = Extract<
ModelMessage,
{ readonly role: TRole }
>

function buildRevisionFeedback(errors: readonly string[]): string {
return [
"Your finalize output did not satisfy the output contract:",
Expand Down
47 changes: 47 additions & 0 deletions src/components/workspace-source-state.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -104,6 +104,53 @@ describe("workspaceSourceState", () => {
).toBe("source_localized");
});

it("keeps an explicit non-ready Source selected instead of falling back", () => {
const sources: readonly SourceView[] = [
{
id: "source_pending",
title: "pending.pdf",
status: "parsing",
mimeType: "application/pdf",
excludedFromQuery: false,
},
{
id: "source_ready",
title: "ready.pdf",
status: "ready",
mimeType: "application/pdf",
excludedFromQuery: false,
},
];

expect(
workspaceSourceState.getResolvedSelectedSourceId(
sources,
"source_pending",
),
).toBe("source_pending");
});

it("does not resolve a stale selected Source to an unrelated ready Source", () => {
const sources: readonly SourceView[] = [
{
id: "demo_ready",
kind: "demo",
demoSourceId: "demo_ready",
title: "demo.pdf",
status: "ready",
mimeType: "application/pdf",
excludedFromQuery: false,
},
];

expect(
workspaceSourceState.getResolvedSelectedSourceId(
sources,
"source_stale",
),
).toBeNull();
});

it("applies source query exclusions without mutating the source list", () => {
const sources: readonly SourceView[] = [
{
Expand Down
6 changes: 4 additions & 2 deletions src/components/workspace-source-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -69,8 +69,10 @@ function getResolvedSelectedSourceId(
sources: readonly SourceView[],
selectedSourceId: string | null,
): string | null {
if (!selectedSourceId) return getFirstReadySourceId(sources)

const selectedSource = sources.find((source) => source.id === selectedSourceId)
if (selectedSource && isReadySource(selectedSource)) {
if (selectedSource) {
return selectedSource.id
}

Expand All @@ -83,7 +85,7 @@ function getResolvedSelectedSourceId(
if (localizedSource) return localizedSource.id
}

return getFirstReadySourceId(sources)
return null
}

function isReadySource(source: SourceView): boolean {
Expand Down
50 changes: 50 additions & 0 deletions src/domains/sources/route-archive.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,6 +72,8 @@ const archiveSourceEffect = (
)
yield* Effect.tryPromise(() =>
client.documents.archive(source.knowhereDocumentId!),
).pipe(
Effect.catchIf(isKnowhereDocumentNotFoundError, () => Effect.void),
)
}

Expand All @@ -92,4 +94,52 @@ const archiveSourceEffect = (
return routeResult.ok({ id: input.sourceId, archived: true as const })
})

function isKnowhereDocumentNotFoundError(error: unknown): boolean {
return readErrorMessages(error).some(isDocumentNotFoundMessage)
}

function readErrorMessages(error: unknown): readonly string[] {
if (error instanceof Error) {
return [
error.message,
...readNestedErrorMessages(error),
].filter(isNonEmptyString)
}

if (typeof error === "string") return [error]

if (!isRecord(error)) return []

const messages: string[] = []
if (typeof error.message === "string") messages.push(error.message)
messages.push(...readNestedErrorMessages(error))
return messages
}

function readNestedErrorMessages(error: unknown): readonly string[] {
if (!isRecord(error)) return []

const nested = [
error.error,
error.cause,
isRecord(error.body) ? error.body.error : undefined,
]

return nested.flatMap((value) =>
value === undefined || value === error ? [] : readErrorMessages(value),
)
}

function isDocumentNotFoundMessage(message: string): boolean {
return /\bdocument\s+not\s+found\b/iu.test(message)
}

function isNonEmptyString(value: string): boolean {
return value.trim().length > 0
}

function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null
}

export { createRouteArchive }
Loading
Loading