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
31 changes: 25 additions & 6 deletions components/chat/chat-page.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -186,6 +186,7 @@ import {
getUserMessageText,
hydrateChatMessage,
isHydratedAttachmentPart,
normalizeConversationTitle,
parseApiErrorResponse,
persistMessagesForConversation,
releaseAttachmentPreview,
Expand All @@ -194,6 +195,7 @@ import {
resolveModelSelectPlaceholder,
resolveStickToBottom,
resolveStreamErrorContent,
shouldUseHarnessRuntime,
trimConversation,
validateAttachmentCompatibility,
type ChatMessage,
Expand Down Expand Up @@ -734,7 +736,7 @@ export function ChatPage() {
modelId: selectedModelId || undefined,
projectId: activeProjectId ?? undefined,
providerId: selectedProviderId || undefined,
title: titleSeed || "Nova conversa",
title: normalizeConversationTitle(titleSeed),
})

setActiveConversationId(response.conversation.id)
Expand Down Expand Up @@ -1491,9 +1493,10 @@ export function ChatPage() {
})
setBrowserProviderAuthState("signed-in")
} else if (
!temporaryChat &&
selectedProviderId !== AUTO_PROVIDER_ID &&
selectedModel?.capabilities.tools === true
shouldUseHarnessRuntime({
supportsTools: selectedModel?.capabilities.tools === true,
temporaryChat,
})
) {
harnessConversationId = await ensureConversationId(
(text || currentAttachments[0]?.fileName || "Nova conversa").slice(
Expand Down Expand Up @@ -1588,6 +1591,12 @@ export function ChatPage() {
updateAssistantToolCall(assistantMessageId, call)
}
}
if (
event.type === "assistant/message" &&
typeof event.payload.modelLabel === "string"
) {
effectiveModelLabel = event.payload.modelLabel
}
},
)
fullText += result.text
Expand Down Expand Up @@ -2292,7 +2301,10 @@ export function ChatPage() {
value={selectedProviderId}
onValueChange={setSelectedProviderId}
>
<SelectTrigger className="h-8 w-auto max-w-[min(200px,55vw)] shrink-0 text-xs sm:min-w-[140px] sm:max-w-[200px]">
<SelectTrigger
aria-label="Selecionar provider"
className="h-8 w-auto max-w-[min(200px,55vw)] shrink-0 text-xs sm:min-w-[140px] sm:max-w-[200px]"
>
<SelectValue placeholder="Provider" />
</SelectTrigger>
<SelectContent>
Expand Down Expand Up @@ -2402,7 +2414,10 @@ export function ChatPage() {
models.length === 0
}
>
<SelectTrigger className="h-8 w-auto max-w-[min(240px,60vw)] shrink-0 text-xs sm:min-w-[140px] sm:max-w-[240px]">
<SelectTrigger
aria-label="Selecionar modelo"
className="h-8 w-auto max-w-[min(240px,60vw)] shrink-0 text-xs sm:min-w-[140px] sm:max-w-[240px]"
>
<SelectValue
placeholder={resolveModelSelectPlaceholder({
hasModels: !!selectedProvider?.hasModels,
Expand Down Expand Up @@ -3277,6 +3292,7 @@ export function ChatPage() {
<TooltipTrigger asChild>
<span>
<Button
aria-label="Anexar arquivo"
variant="ghost"
size="icon-xs"
className="size-8 md:size-6"
Expand Down Expand Up @@ -3310,6 +3326,7 @@ export function ChatPage() {
value={activeProjectId ?? "__none__"}
>
<SelectTrigger
aria-label="Selecionar projeto da conversa"
className="h-7 w-auto max-w-[150px] shrink-0 border-dashed text-[11px]"
title="Projeto do contexto da conversa"
>
Expand All @@ -3336,6 +3353,7 @@ export function ChatPage() {
</div>
{pending ? (
<Button
aria-label="Parar geração"
size="sm"
variant="destructive"
className="h-7 text-xs"
Expand All @@ -3346,6 +3364,7 @@ export function ChatPage() {
</Button>
) : (
<InputGroupButton
aria-label="Enviar mensagem"
size="sm"
disabled={
(!input.trim() && attachments.length === 0) ||
Expand Down
15 changes: 15 additions & 0 deletions lib/chat-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,16 +10,31 @@ import {
formatMessageTimestamp,
getUserMessageText,
hydrateChatMessage,
normalizeConversationTitle,
parseApiErrorResponse,
resolveAssistantModelLabel,
resolveModelFallbackFromHeaders,
resolveModelSelectPlaceholder,
resolveStickToBottom,
resolveStreamErrorContent,
shouldUseHarnessRuntime,
STREAM_INTERRUPTED_NOTE,
validateAttachmentCompatibility,
} from "./chat-utils";

describe("conversation runtime helpers", () => {
it("normalizes long prompts into valid conversation titles", () => {
expect(normalizeConversationTitle(` ${"x".repeat(250)} `)).toBe("x".repeat(200));
expect(normalizeConversationTitle(" ")).toBe("Nova conversa");
});

it("uses the harness for every durable tool-capable provider, including Auto", () => {
expect(shouldUseHarnessRuntime({ supportsTools: true, temporaryChat: false })).toBe(true);
expect(shouldUseHarnessRuntime({ supportsTools: false, temporaryChat: false })).toBe(false);
expect(shouldUseHarnessRuntime({ supportsTools: true, temporaryChat: true })).toBe(false);
});
});

const MODELS: ProviderModel[] = [
{ capabilities: { documents: true, images: false }, id: "gpt-4o", name: "GPT-4o" },
{ capabilities: { documents: true, images: true }, id: "gpt-4o-mini", name: "GPT-4o mini" },
Expand Down
14 changes: 14 additions & 0 deletions lib/chat-utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -106,6 +106,20 @@ export const EMPTY_STATE_PROMPTS = [
"Me ajude a diagnosticar um erro 500.",
] as const

export const MAX_CONVERSATION_TITLE_LENGTH = 200

export function normalizeConversationTitle(titleSeed: string): string {
const title = titleSeed.trim() || "Nova conversa"
return title.slice(0, MAX_CONVERSATION_TITLE_LENGTH)
}

export function shouldUseHarnessRuntime(input: {
supportsTools: boolean
temporaryChat: boolean
}): boolean {
return !input.temporaryChat && input.supportsTools
}

export type ChatRequestError = Error & {
status?: number
suppressToast?: boolean
Expand Down
106 changes: 103 additions & 3 deletions lib/harness/harness.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,100 @@ import { join } from "node:path"
import { parseChatStream } from "../chat-stream"
import { consumeHarnessStream } from "./client"
import type { HarnessEvent } from "./contracts"
import { compactModelMessages } from "../../server/harness/prompt"
import {
compactModelMessages,
HARNESS_COMPLETION_GUIDANCE,
HARNESS_ITERATIVE_DELIVERY_GUIDANCE,
HARNESS_NO_PROJECT_GUIDANCE,
HARNESS_TOOL_USE_GUIDANCE,
} from "../../server/harness/prompt"
import { HarnessRegistry } from "../../server/harness/registry"
import { consumeHarnessModelResponse } from "../../server/harness/model-stream"
import {
consumeHarnessModelResponse,
isModelOutputLimitFinishReason,
} from "../../server/harness/model-stream"
import { assertPublicHttpUrl } from "../../server/harness/core-tools"
import { collectHarnessEventPages } from "../../server/harness/events"
import { toHarnessJson } from "../../server/harness/json"
import { assertSecureMcpUrl, mcpToolRisk, readMcpRpcResponse, safeMcpToolName } from "../../server/harness/mcp"
import { parseSingleMessagePart } from "../../server/lib/conversation-attachments"
import { limitHarnessToolCalls, MAX_TOOL_CALLS_PER_STEP } from "../../server/harness/runtime"
import {
limitHarnessToolCalls,
MAX_TOOL_CALLS_PER_STEP,
selectHarnessToolSchemas,
} from "../../server/harness/runtime"

describe("harness stream protocol", () => {
it("keeps ordinary multi-role refinement from pausing for internal tracking approval", () => {
expect(HARNESS_TOOL_USE_GUIDANCE).toContain("creator/critic")
expect(HARNESS_TOOL_USE_GUIDANCE).toContain("goal_write")
expect(HARNESS_TOOL_USE_GUIDANCE).toContain("pause for approval")
})

it("recognizes provider finish reasons that require automatic continuation", () => {
expect(isModelOutputLimitFinishReason("length")).toBe(true)
expect(isModelOutputLimitFinishReason("max_tokens")).toBe(true)
expect(isModelOutputLimitFinishReason("max-output-tokens")).toBe(true)
expect(isModelOutputLimitFinishReason("stop")).toBe(false)
expect(HARNESS_COMPLETION_GUIDANCE).toContain("numbered round")
expect(HARNESS_NO_PROJECT_GUIDANCE).toContain("project_file_write")
expect(HARNESS_ITERATIVE_DELIVERY_GUIDANCE).toContain("final round")
})

it("hides unusable stateful tools for an ordinary multi-role artifact request", () => {
const tool = (name: string) => ({
function: { description: name, name, parameters: { type: "object" as const } },
type: "function" as const,
})
const selected = selectHarnessToolSchemas({
messages: [{ content: "Atue como criador e crítico e crie um jogo", role: "user" }],
projectId: null,
tools: [
tool("goal_write"),
tool("todo_write"),
tool("subagent"),
tool("project_file_write"),
tool("web_search"),
tool("memory_search"),
tool("session_event_search"),
],
})
expect(selected.map((item) => item.function.name)).toEqual([])
})

it("exposes stateful tools only for explicit persistence or delegation requests", () => {
const tool = (name: string) => ({
function: { description: name, name, parameters: { type: "object" as const } },
type: "function" as const,
})
const selected = selectHarnessToolSchemas({
messages: [{ content: "Salve um plano persistente e delegue a um subagente", role: "user" }],
projectId: "project-1",
tools: [tool("plan_write"), tool("subagent"), tool("project_file_write")],
})
expect(selected.map((item) => item.function.name)).toEqual([
"plan_write",
"subagent",
"project_file_write",
])
})

it("exposes web tools only when the user explicitly requests research", () => {
const tool = (name: string) => ({
function: { description: name, name, parameters: { type: "object" as const } },
type: "function" as const,
})
const selected = selectHarnessToolSchemas({
messages: [{ content: "Pesquise na web e cite fontes", role: "user" }],
projectId: null,
tools: [tool("web_search"), tool("web_fetch"), tool("goal_write")],
})
expect(selected.map((item) => item.function.name)).toEqual([
"web_search",
"web_fetch",
])
})

it("parses streamed harness events and terminal status", async () => {
const events: HarnessEvent[] = [
{
Expand Down Expand Up @@ -155,6 +238,23 @@ describe("harness stream protocol", () => {
})
})

it("keeps the effective Auto routing metadata", async () => {
const result = await consumeHarnessModelResponse(
new Response('0:"ok"\nd:{"finishReason":"stop"}\n', {
headers: {
"x-modelhub-model": "qwen3.5-27b",
"x-modelhub-provider": "groq",
"x-modelhub-tier": "reasoning",
},
}),
)
expect(result.routing).toEqual({
modelId: "qwen3.5-27b",
providerId: "groq",
tier: "reasoning",
})
})

it("persists streamed deltas in source order even when writes have different latency", async () => {
const observed: string[] = []
await consumeHarnessModelResponse(
Expand Down
22 changes: 21 additions & 1 deletion server/harness/core-tools.ts
Original file line number Diff line number Diff line change
Expand Up @@ -97,7 +97,27 @@ export async function safeFetchPublicHttpUrl(
const headers = Object.fromEntries(new Headers(init.headers).entries())
headers.host = url.host
const pinnedLookup: LookupFunction = (_hostname, _options, callback) => {
callback(null, address, family)
const wantsAll =
typeof _options === "object" &&
_options !== null &&
"all" in _options &&
_options.all === true
if (wantsAll) {
;(
callback as (
error: NodeJS.ErrnoException | null,
addresses: Array<{ address: string; family: number }>,
) => void
)(null, [{ address, family }])
return
}
;(
callback as (
error: NodeJS.ErrnoException | null,
resolvedAddress: string,
resolvedFamily: number,
) => void
)(null, address, family)
}
return new Promise<Response>((resolve, reject) => {
const request = (url.protocol === "https:" ? httpsRequest : httpRequest)(
Expand Down
22 changes: 22 additions & 0 deletions server/harness/model-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,10 +3,21 @@ import type { HarnessToolCall } from "../../lib/harness/contracts"

export type HarnessModelResult = {
finishReason: string
routing?: {
modelId: string
providerId: string
tier: string | null
}
text: string
toolCalls: HarnessToolCall[]
}

export function isModelOutputLimitFinishReason(finishReason: string): boolean {
return /^(?:length|max[_-]?(?:output[_-]?)?tokens?|token[_-]?limit)$/i.test(
finishReason.trim(),
)
}

export async function consumeHarnessModelResponse(
response: Response,
onTextDelta?: (delta: string) => void | Promise<void>,
Expand Down Expand Up @@ -36,8 +47,19 @@ export async function consumeHarnessModelResponse(
await pendingDeltaWrites

if (parsed.errorMessage) throw new Error(parsed.errorMessage)
const routingProviderId = response.headers.get("x-modelhub-provider")
const routingModelId = response.headers.get("x-modelhub-model")
return {
finishReason: parsed.finishReason ?? (toolCalls.size > 0 ? "tool-calls" : "stop"),
...(routingProviderId && routingModelId
? {
routing: {
modelId: routingModelId,
providerId: routingProviderId,
tier: response.headers.get("x-modelhub-tier"),
},
}
: {}),
text: parsed.text,
toolCalls: [...toolCalls.values()],
}
Expand Down
19 changes: 18 additions & 1 deletion server/harness/prompt.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,18 @@ import { prisma } from "../lib/db"
import { listAllHarnessEvents } from "./events"
import type { HarnessRegistry } from "./registry"

export const HARNESS_TOOL_USE_GUIDANCE =
"Do not call goal_write, plan_write, todo_write, or subagent merely to restate, track, or role-play an ordinary user task. These tools pause for approval. Complete requested creator/critic or multi-role refinement directly unless the user explicitly asks to persist tracking state or delegate a separate task."

export const HARNESS_COMPLETION_GUIDANCE =
"Before ending, verify that every explicitly requested numbered round, section, and deliverable is present. If the latest assistant content is visibly truncated or the requested format is incomplete, continue exactly where it stopped without repeating completed content."

export const HARNESS_NO_PROJECT_GUIDANCE =
"No active project is attached. Do not call project_context, project_file_read, or project_file_write. Deliver requested code and artifacts directly in the response."

export const HARNESS_ITERATIVE_DELIVERY_GUIDANCE =
"For multi-round refinement, keep non-final artifact deliveries concise: state concrete changes and show only the changed excerpts or patch. Reserve the full self-contained artifact for the final round unless the user explicitly requires the complete artifact in every round."

export type ModelMessage = {
content: string | unknown[]
name?: string
Expand Down Expand Up @@ -156,10 +168,15 @@ export async function buildHarnessSystemPrompt(input: {
return [
"You are ModelHub's agent runtime. Work in explicit, bounded steps and use tools when they materially improve the answer.",
"Never claim that a tool ran unless a tool result is present. Respect approval denials and capability limits.",
HARNESS_TOOL_USE_GUIDANCE,
HARNESS_COMPLETION_GUIDANCE,
HARNESS_ITERATIVE_DELIVERY_GUIDANCE,
settings?.customInstructionsAbout ? `User context:\n${settings.customInstructionsAbout}` : "",
settings?.customInstructionsStyle ? `Response preferences:\n${settings.customInstructionsStyle}` : "",
memories.length ? `Saved memories:\n${memories.map((memory) => `- ${memory.content}`).join("\n")}` : "",
project ? `Active project: ${project.name}\n${project.instructions ?? ""}` : "",
project
? `Active project: ${project.name}\n${project.instructions ?? ""}`
: HARNESS_NO_PROJECT_GUIDANCE,
skills.length
? `Active skills:\n${skills.map((skill) => `## ${skill.name}\n${skill.content}`).join("\n\n")}`
: "",
Expand Down
Loading
Loading