Skip to content

Web Search Agent

这个示例构建一个可以搜索网页的 agent:

  1. 用户提出问题。
  2. LLM 判断是否需要调用 web_search
  3. Tool 调用 Exa hosted MCP server。
  4. 搜索结果返回给 LLM。
  5. LLM 基于搜索结果生成最终回答。

示例使用 Exa hosted MCP endpoint:https://mcp.exa.ai/mcp,因此不需要 Exa API key。

ts
import {
  anthropic,
  createAgent,
  memoryStorage,
  tool,
} from "duclaw-cli/sdk";

type ExaSearchResult = {
  title?: string;
  url?: string;
  text?: string;
  publishedDate?: string;
};

type JsonRpcResponse<T> = {
  result?: T;
  error?: {
    message?: string;
  };
};

const EXA_MCP_URL = "https://mcp.exa.ai/mcp";

// 辅助代码:调用 Exa hosted MCP server。这是 adapter 代码,不是 SDK 的特殊逻辑。
async function callExaTool<T>(name: string, args: Record<string, unknown>): Promise<T> {
  const response = await fetch(EXA_MCP_URL, {
    method: "POST",
    headers: {
      "content-type": "application/json",
      accept: "application/json, text/event-stream",
    },
    body: JSON.stringify({
      jsonrpc: "2.0",
      id: crypto.randomUUID(),
      method: "tools/call",
      params: {
        name,
        arguments: args,
      },
    }),
  });

  if (!response.ok) {
    throw new Error(`Exa MCP request failed: ${response.status}`);
  }

  const body = await response.text();
  const payload = parseMcpResponse<JsonRpcResponse<T>>(body);
  if (payload.error) {
    throw new Error(payload.error.message ?? "Exa MCP tool call failed");
  }
  if (!payload.result) {
    throw new Error("Exa MCP returned an empty result");
  }
  return payload.result;
}

// 辅助代码:Exa MCP 可能返回 JSON,也可能返回 text/event-stream message。
function parseMcpResponse<T>(body: string): T {
  const eventData = body
    .split("\n")
    .find((line) => line.startsWith("data: "))
    ?.slice("data: ".length);

  return JSON.parse(eventData ?? body) as T;
}

// SDK 关键点:tool 是 LLM 可以在 agent loop 中选择调用的类型化能力。
const webSearch = tool({
  name: "web_search",
  description: "Search the web for current information and return concise results.",
  inputSchema: {
    type: "object",
    properties: {
      query: {
        type: "string",
        description: "The web search query.",
      },
      numResults: {
        type: "number",
        description: "Number of search results to return.",
      },
    },
    required: ["query"],
    additionalProperties: false,
  },
  async run({ query, numResults = 5 }) {
    // Adapter 边界:tool 可以调用任意外部服务,并把结构化结果返回给 LLM。
    const result = await callExaTool<{ content?: Array<{ text?: string }> }>(
      "web_search_exa",
      {
        query,
        numResults,
        type: "fast",
      },
    );

    const text = result.content?.map((item) => item.text).filter(Boolean).join("\n\n") ?? "";
    return normalizeSearchResults(text);
  },
});

// 辅助代码:把 provider-specific output 规范化,再交给 LLM。
function normalizeSearchResults(raw: string): ExaSearchResult[] {
  try {
    const parsed = JSON.parse(raw);
    return Array.isArray(parsed.results) ? parsed.results : [];
  } catch {
    return [{ title: "Search results", text: raw }];
  }
}

// SDK 关键点:宿主应用显式注入 model、storage、tools 和 system prompt。
const agent = createAgent({
  model: anthropic({ apiKey: process.env.ANTHROPIC_API_KEY! }),
  storage: memoryStorage(),
  tools: [webSearch],
  system: [
    "You answer developer questions with current web context when needed.",
    "Use web_search for recent facts, package versions, docs, or news.",
    "Cite source URLs from the search results in the final answer.",
  ].join("\n"),
});

// 应用代码:把用户输入交给 agent loop,并输出最终 LLM 回答。
async function main() {
  const result = await agent.run({
    input: "What changed in the latest VitePress release?",
    context: {
      userId: "developer",
      requestId: crypto.randomUUID(),
    },
  });

  console.log(result.output);
}

void main().catch((error) => {
  console.error(error);
  process.exitCode = 1;
});

Tool Loop

SDK 负责 agent loop。应用负责提供 model、storage 和 tools:

txt
用户输入
  -> createAgent(...).run()
  -> LLM 选择 web_search
  -> Exa MCP 返回搜索结果
  -> LLM 接收 tool result
  -> 最终回答

关键边界是:web_search 只是一个普通 SDK tool。你可以把 Exa 替换成其它搜索服务,而不需要改变 agent API。

随 duclaw-cli package 一起发布。