File size: 2,788 Bytes
78cc96f
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
// src/agent/runtime.js
// ReAct-style agent loop. Model-agnostic: works with any `llm.chat(...)` that
// streams tokens via onToken and resolves to a string or { text }.

import { DEFAULT_SYSTEM } from "./webllm-agent.js";
import { TOOL_HELP } from "./webllm-agent.js";

export function createAgentRuntime({ llm, tools, onLog, onToken, onState }) {
  let running = false;
  let aborter = null;

  async function runTool(name, args) {
    if (!tools[name]) throw new Error(`Unknown tool: ${name}`);
    return await tools[name](args || {});
  }

  async function loop(userText, messages = [], maxIter = 8) {
    if (running) return;
    running = true;
    aborter = new AbortController();
    onState?.({ running: true });

    const chat = [
      { role: "system", content: `${DEFAULT_SYSTEM}\n\nAvailable tools:\n${TOOL_HELP}` },
      ...messages,
      { role: "user", content: userText },
    ];

    let last = "";
    try {
      for (let iter = 0; iter < maxIter; iter++) {
        onLog?.(`iter ${iter + 1}/${maxIter}`);
        last = "";
        const out = await llm.chat({
          messages: chat,
          signal: aborter.signal,
          stream: true,
          temperature: 0.4,
          max_tokens: 900,
          onToken: (t) => {
            last += t;
            onToken?.(t);
          },
        });

        const text = typeof out === "string" ? out : (out?.text || last);
        const actionMatch = text.match(/action\s*:\s*(\{[\s\S]*?\})/i);

        if (!actionMatch) {
          onState?.({ running: false, final: text });
          return text;
        }

        let action;
        try {
          action = JSON.parse(actionMatch[1]);
        } catch {
          chat.push({ role: "assistant", content: text });
          chat.push({ role: "user", content: "Observation: action JSON parse error. Re-emit valid JSON." });
          continue;
        }

        onLog?.(`tool: ${action.tool}(${JSON.stringify(stripTool(action))})`);
        let result;
        try {
          result = await runTool(action.tool, action);
        } catch (err) {
          result = `tool error: ${err?.message || err}`;
        }
        chat.push({ role: "assistant", content: text });
        chat.push({
          role: "user",
          content: `Observation from ${action.tool}: ${String(result).slice(0, 8000)}`,
        });
      }
      onState?.({ running: false, final: last });
      return last;
    } finally {
      running = false;
      aborter = null;
      onState?.({ running: false });
    }
  }

  function stop() {
    if (aborter) aborter.abort();
    running = false;
    onState?.({ running: false });
  }

  return { loop, stop, isRunning: () => running };
}

function stripTool(action) {
  const { tool, ...rest } = action;
  return rest;
}