File size: 5,987 Bytes
39371ea
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
import { DOMParser } from '@xmldom/xmldom';

export const textContent = content => typeof content === 'string' ? content :
  (content ?? []).filter(c => c.type === 'text').map(c => c.text).join('\n');

export function toMiniMessages(context) {
  const messages = context.messages.filter(m => m.role !== 'assistant' || !['error', 'aborted'].includes(m.stopReason));
  return [{ role: 'system', content: context.systemPrompt }, ...messages.map(m => {
    if (m.role === 'toolResult') return { role: 'tool', content: JSON.stringify({
      name: m.toolName, isError: m.isError, output: textContent(m.content),
    }) };
    if (m.role === 'assistant') return { role: 'assistant',
      // Explicit even when empty: the upstream template otherwise mistakes
      // literal </think> inside code or tool arguments for a reasoning boundary.
      reasoning_content: m.content.filter(c => c.type === 'thinking').map(c => c.thinking).join('\n'),
      content: m.content.map(c => c.type === 'text' ? c.text : c.type === 'toolCall' ? '<tool_sep>' : '').join(''),
      tool_calls: m.content.filter(c => c.type === 'toolCall').map(c => ({
        type: 'function', function: { name: c.name, arguments: c.arguments },
      })) };
    return { role: 'user', content: textContent(m.content) };
  })];
}

// Scan function boundaries outside CDATA; regex alone breaks shell commands
// containing XML (including literal </function> inside a here-document).
function functionEnd(raw, start) {
  let at = start;
  while (at < raw.length) {
    const cdata = raw.indexOf('<![CDATA[', at);
    const end = raw.indexOf('</function>', at);
    if (end < 0) throw Error('Incomplete tool call. No command was executed. Try again with a shorter task.');
    if (cdata < 0 || cdata > end) return end + 11;
    const close = raw.indexOf(']]>', cdata + 9);
    if (close < 0) throw Error('Incomplete CDATA in tool call. No command was executed.');
    at = close + 3;
  }
}

function nextFunction(raw, start) {
  let fence = null, inline = 0;
  for (let at = start; at < raw.length;) {
    if (at === 0 || raw[at - 1] === '\n') {
      const line = raw.slice(at).split('\n')[0];
      const marker = /^ {0,3}(`{3,}|~{3,})/.exec(line)?.[1];
      if (marker) {
        if (!fence) fence = marker;
        else if (marker[0] === fence[0] && marker.length >= fence.length) fence = null;
        at += line.length + 1; continue;
      }
    }
    if (fence) { at++; continue; }
    if (raw[at] === '`') {
      const count = /^`+/.exec(raw.slice(at))[0].length;
      if (!inline) inline = count; else if (inline === count) inline = 0;
      at += count; continue;
    }
    if (!inline && /^<function\s/.test(raw.slice(at))) return at;
    at++;
  }
  return -1;
}

export function splitThinking(raw, { thinkingPrefilled = false } = {}) {
  const opening = /^\s*<think>\n?/.exec(raw);
  if (!thinkingPrefilled && !opening) return { thinking: '', answer: raw, complete: true };
  const start = opening?.[0].length ?? 0;
  const end = raw.indexOf('</think>', start);
  if (end < 0) return { thinking: raw.slice(start), answer: '', complete: false };
  return { thinking: raw.slice(start, end), answer: raw.slice(end + 8), complete: true };
}

export function parseCompletion(raw, tools, options) {
  raw = raw.replace(/<\|im_end\|>$|<\/s>$/, '');
  const reasoning = splitThinking(raw, options);
  if (!reasoning.complete) throw Error('The model used its response budget before finishing its thoughts. Try a smaller task.');
  raw = reasoning.answer;
  const blocks = reasoning.thinking.trim() ? [{ type: 'thinking', thinking: reasoning.thinking.trim() }] : [];
  let at = 0;
  while (at < raw.length) {
    const start = nextFunction(raw, at);
    if (start < 0) { if (raw.slice(at).trim()) blocks.push({ type: 'text', text: raw.slice(at).trim() }); break; }
    if (raw.slice(at, start).trim()) blocks.push({ type: 'text', text: raw.slice(at, start).trim() });
    const end = functionEnd(raw, start);
    const errors = [];
    const doc = new DOMParser({ onError: (level, message) => errors.push(message) })
      .parseFromString(raw.slice(start, end), 'application/xml');
    if (errors.length || doc.doctype || doc.documentElement.tagName !== 'function') throw Error('Malformed model tool call.');
    const fn = doc.documentElement, name = fn.getAttribute('name');
    const tool = tools.find(t => t.name === name);
    if (!tool) throw Error('Unknown model tool: ' + name);
    const args = Object.create(null);
    for (const node of Array.from(fn.childNodes)) {
      if (node.nodeType === 3 && !node.textContent.trim()) continue;
      if (node.nodeType !== 1 || node.tagName !== 'param') throw Error('Unexpected content in tool call.');
      const key = node.getAttribute('name');
      if (Object.hasOwn(args, key) || !Object.hasOwn(tool.parameters.properties, key)) throw Error('Unknown or duplicate tool parameter: ' + key);
      if (Array.from(node.childNodes).some(c => ![3, 4].includes(c.nodeType))) throw Error('Tool values containing XML must use CDATA.');
      const schema = tool.parameters.properties[key];
      args[key] = schema.type === 'string' ? node.textContent : JSON.parse(node.textContent);
    }
    blocks.push({ type: 'toolCall', id: crypto.randomUUID(), name, arguments: args });
    at = end;
  }
  return blocks;
}

// Keep complete user turns together so tool results never lose their calls.
export function fitContext(context, tokenize, maxPromptTokens) {
  const messages = [...context.messages];
  let dropped = 0;
  while (true) {
    const inputs = tokenize(toMiniMessages({ ...context, messages }));
    if (inputs.input_ids.dims[1] <= maxPromptTokens) return { inputs, dropped };
    const nextUser = messages.findIndex((m, i) => i > 0 && m.role === 'user');
    if (nextUser < 0) throw Error('This task exceeds the browser context budget. Start a new chat or use smaller files and shorter tool output.');
    messages.splice(0, nextUser); dropped += nextUser;
  }
}