Harnesses with AI SDK UI
Harnesses with AI SDK UI
Harness 스트림은 AI SDK UI 메시지 스트림과 호환돼요. 클라이언트에서 useChat()을 사용하고 서버 라우트에서 HarnessAgent 출력을 스트리밍할 수 있어요.
모델 기반 채팅 라우트와의 중요한 차이는 세션 관리예요. Harness는 자체 대화 상태를 소유하므로, 라우트는 전체 UI 메시지 히스토리를 모델로 다시 재생하는 대신 chat id에 대해 HarnessAgentSession을 이어서(resume) 만들거나 생성해야 해요.
출처: 문서
본문
클라이언트
'use client';
import { useChat } from '@ai-sdk/react';
import { DefaultChatTransport } from 'ai';
import { useState } from 'react';
export default function Page() {
const [input, setInput] = useState('');
const { error, messages, sendMessage, status } = useChat({
id: 'example-chat',
transport: new DefaultChatTransport({
api: '/api/chat',
}),
});
return (
<>
{messages.map(message => (
<div key={message.id}>
<strong>{message.role === 'user' ? 'You: ' : 'AI: '}</strong>
{message.parts.map((part, index) => {
if (part.type === 'text') {
return <span key={index}>{part.text}</span>;
}
if (part.type.startsWith('tool-') || part.type === 'dynamic-tool') {
return <pre key={index}>{JSON.stringify(part, null, 2)}</pre>;
}
return null;
})}
</div>
))}
{error && <div>{error.message}</div>}
<form
onSubmit={event => {
event.preventDefault();
if (input.trim()) {
sendMessage({ text: input });
setInput('');
}
}}
>
<input
value={input}
onChange={event => setInput(event.target.value)}
disabled={status !== 'ready'}
/>
<button type="submit" disabled={status !== 'ready'}>
Send
</button>
</form>
</>
);
}
에이전트
서버에서 HarnessAgent를 정의해요:
import { HarnessAgent } from '@ai-sdk/harness/agent';
import { claudeCode } from '@ai-sdk/harness-claude-code';
export const agent = new HarnessAgent({
harness: claudeCode,
instructions: 'You are a helpful coding assistant.',
});
세션 스토어
session.detach()가 반환하는 불투명한 재개 상태(resume state)를 영속화해요. 턴이 승인으로 인해 일시 중지됐거나 어떤 이유로 중단됐다면, 그 재개 상태는 내부적으로 연속 상태를 지녀요. chat id는 harness sessionId가 될 수도 있어요. 여기서는 알려진 chat id에서 안정적인 sandboxId를 도출해요. 도출하지 않는다면 반환된 sandboxSession.id를 별도로 영속화하세요.
import type { HarnessAgentResumeSessionState } from '@ai-sdk/harness/agent';
import {
createVercelNetworkSandboxSession,
resumeVercelNetworkSandboxSession,
} from '@ai-sdk/sandbox-vercel';
const states: Record<string, HarnessAgentResumeSessionState | undefined> = {};
export async function resumeOrCreateSession({
agent,
chatId,
}: {
agent: typeof import('./agent').agent;
chatId: string;
}) {
const resumeFrom = states[chatId];
const sandboxId = `harness-${chatId}`;
const sandboxSession = resumeFrom
? await resumeVercelNetworkSandboxSession({ sandboxId })
: await createVercelNetworkSandboxSession({
sandboxId,
runtime: 'node24',
ports: [4000],
template: await agent.getSandboxTemplate(),
});
const session = await agent.createSession({
sessionId: chatId,
resumeFrom,
sandboxSession,
});
return { session, sandboxSession };
}
export async function detachAndPersist({
chatId,
session,
}: {
chatId: string;
session: Awaited<ReturnType<typeof resumeOrCreateSession>>['session'];
}) {
states[chatId] = await session.detach();
}
프로덕션에서는 인메모리 맵 대신 영구 스토리지를 사용하세요. 생성자는 항상 새 샌드박스를 만들고 충돌하는 sandboxId에 대해 실패해요. 기존 샌드박스를 다시 연결하는 것은 재개(resume) 함수뿐이에요.
라우트
UI 메시지를 모델 메시지로 변환하고, harness 턴을 실행하고, 결과 스트림을 UI 메시지 스트림으로 다시 변환해요:
import { agent } from './agent';
import { detachAndPersist, resumeOrCreateSession } from './session-store';
import { getHarnessErrorMessage } from '@ai-sdk/harness/agent';
import {
convertToModelMessages,
createUIMessageStream,
createUIMessageStreamResponse,
toUIMessageStream,
type UIMessage,
} from 'ai';
export async function POST(request: Request) {
const body: {
id?: string;
messages: UIMessage[];
} = await request.json();
if (!body.id) {
throw new Error('Missing chat id');
}
const chatId = body.id;
const messages = await convertToModelMessages(body.messages);
return createUIMessageStreamResponse({
stream: createUIMessageStream({
execute: async ({ writer }) => {
const { session } = await resumeOrCreateSession({ agent, chatId });
const result = await agent.stream({ session, messages });
writer.merge(
toUIMessageStream({
stream: result.stream,
onError: getHarnessErrorMessage,
onEnd: async () => {
await detachAndPersist({ chatId, session });
},
}),
);
},
onError: getHarnessErrorMessage,
}),
});
}
세션을 획득하기 전에 UI 메시지 스트림을 만들면 샌드박스, 부트스트랩, harness 시작 실패가 일반 HTTP 오류가 아닌 UI 오류 부분으로 전송돼요. getHarnessErrorMessage은 검토된 클라이언트 안전 harness 메시지를 보존하고 알 수 없는 서버 오류를 마스킹해요.
필요한 세션을 주입하도록 에이전트를 래핑하지 않는 한 HarnessAgent에 createAgentUIStreamResponse를 직접 사용하지 마세요. HarnessAgent.stream()은 모든 호출에서 session을 요구해요.
Detach 또는 Stop
다음 요청을 위해 harness 런타임을 대기(park) 상태로 두고 샌드박스를 유지하려면 session.detach()을 사용해요. 브리지 기반 어댑터는 일반적으로 효율적으로 재연결하거나 재생할 수 있어요. 턴이 끝나지 않았다면 detach()는 반환된 재개 상태에 턴 연속 상태를 포함해요.
session.stop()은 재개 상태를 저장하고 harness 런타임을 중지하지만, 제공된 샌드박스를 중지하지는 않아요. 호출자는 그 네트워크 세션을 소유하며, 채팅이 끝날 때(나중의 요청이 재연결을 필요로 할 때가 아니라) 그 destroy() 메서드를 호출해야 해요. 선택한 sandboxId를 프로세스 간에 보존하세요.
Harness 파트 렌더링
Harness 출력은 AI SDK 모델 스트림에서 사용하는 동일한 UI 메시지 파트 형태를 포함해요:
- 생성 콘텐츠용
text및reasoning파트. tool-bash,tool-read같은 형식화된 도구 파트 또는tool-weather같은 호스트 도구.fileChange및compaction같은 동적 이벤트용dynamic-tool파트.
형식화된 harness 내장 기능은 일반 AI SDK 도구 파트와 동일하게 렌더링해요. part.state의 input-streaming, input-available, output-available을 확인하세요.
타입 세이프 도구 파트
HarnessAgent 세션 옵션이 기본 Agent 호출 파라미터의 일부가 될 때까지, agent.tools에서 UI 도구를 추론해요:
import type { InferUITools, UIMessage } from 'ai';
import { agent } from './agent';
export type HarnessMessage = UIMessage<
unknown,
never,
InferUITools<typeof agent.tools>
>;
그런 다음 클라이언트에서 useChat<HarnessMessage>()을 사용해요.