rewritable 0.1.0 → 0.5.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,155 @@
1
+ // Multi-turn tool-use agent loop against an OpenAI-compatible
2
+ // /chat/completions endpoint. Mirrors the browser runtime's modify() shape in
3
+ // seeds/rewritable.html: a retry budget of 3, single tool_call per turn,
4
+ // corrective user message on no_tool_call, tool_result on invalid_json.
5
+ //
6
+ // Backend HTTP errors are terminal (not retried) for v1 — same posture as the
7
+ // runtime, which surfaces network failures directly to the user.
8
+ //
9
+ // Parallel tool_calls are not supported: the loop takes tool_calls[0] and
10
+ // ignores the rest. The seed's modify() loop is also single-call-per-turn, so
11
+ // this matches existing behavior. Multi-call dispatching is a v2 concern.
12
+ //
13
+ // Note: fetch has no timeout. Callers (Task 7's CLI process or the bridge
14
+ // transport) are responsible for any timeout/cancellation.
15
+
16
+ const RETRY_BUDGET = 3;
17
+
18
+ export class AgentError extends Error {
19
+ constructor(subcode, details = {}) {
20
+ super(subcode);
21
+ this.subcode = subcode;
22
+ this.details = details;
23
+ }
24
+ }
25
+
26
+ /**
27
+ * Run a multi-turn tool-use loop. Returns the first valid envelope produced
28
+ * by the model.
29
+ *
30
+ * @param {object} opts
31
+ * @param {string} opts.systemPrompt - System role content.
32
+ * @param {Array} opts.toolSchemas - Tool definitions (OpenAI-compatible).
33
+ * @param {string} opts.currentDoc - Document content for the user message.
34
+ * @param {string} opts.instruction - User instruction for the user message.
35
+ * @param {string[]} [opts.frozenZoneNames] - Frozen zone names visible to the
36
+ * model. Defaults to `[]`. Surfaces in the user prompt so the model
37
+ * knows which marker-form zones to preserve verbatim.
38
+ * @param {{baseUrl: string, model: string, apiKey?: string}} opts.backend
39
+ * @param {(info: {attempt: number, reason: string, toolName?: string}) => void} [opts.onRetry]
40
+ * Optional callback fired each time a retry is queued. `attempt` is the
41
+ * attempt that just failed (1-indexed).
42
+ * @returns {Promise<{envelope: object, toolName: string, messages: Array}>}
43
+ * @throws {AgentError} subcode: 'no_envelope_after_retries' | 'backend_error'
44
+ */
45
+ export async function runAgentLoop({
46
+ systemPrompt,
47
+ toolSchemas,
48
+ currentDoc,
49
+ instruction,
50
+ frozenZoneNames = [],
51
+ backend,
52
+ onRetry,
53
+ }) {
54
+ // Seed parity (seeds/rewritable.html buildUserPrompt): the user message
55
+ // names the request, lists frozen-zone names so the model knows what to
56
+ // preserve, and fences the doc in <DOC>…</DOC> so the model can't confuse
57
+ // doc bytes with instruction bytes. The CLI surface is a strict subset of
58
+ // the seed's prompt — lock ranges and the long explanatory parenthetical
59
+ // are seed-only.
60
+ const fzText = frozenZoneNames.length === 0 ? '(none)' : frozenZoneNames.join(', ');
61
+ const userContent =
62
+ 'User request:\n' + instruction +
63
+ '\n\nFrozen zones in the current doc: ' + fzText +
64
+ '\n\n<DOC>\n' + currentDoc + '\n</DOC>';
65
+ const messages = [
66
+ { role: 'system', content: systemPrompt },
67
+ { role: 'user', content: userContent },
68
+ ];
69
+
70
+ for (let attempt = 1; attempt <= RETRY_BUDGET; attempt++) {
71
+ let response;
72
+ try {
73
+ response = await callBackend(backend, { messages, tools: toolSchemas });
74
+ } catch (e) {
75
+ // Network or HTTP error — terminal for v1.
76
+ throw new AgentError('backend_error', { attempt, message: e.message });
77
+ }
78
+
79
+ const message = response?.choices?.[0]?.message;
80
+ if (!message) {
81
+ throw new AgentError('backend_error', {
82
+ attempt,
83
+ message: 'malformed response: no message in choices[0]',
84
+ });
85
+ }
86
+
87
+ if (!message.tool_calls || message.tool_calls.length === 0) {
88
+ messages.push(message);
89
+ if (onRetry) onRetry({ attempt, reason: 'no_tool_call', toolName: undefined });
90
+ messages.push({
91
+ role: 'user',
92
+ content: 'Retry: you must call one of the provided tools (no plain text). Try again.',
93
+ });
94
+ continue;
95
+ }
96
+
97
+ // v1: take the first tool_call, ignore any others.
98
+ const call = message.tool_calls[0];
99
+ let envelope;
100
+ try {
101
+ envelope = JSON.parse(call.function.arguments);
102
+ } catch (e) {
103
+ if (onRetry) onRetry({ attempt, reason: 'invalid_json', toolName: call.function.name });
104
+ // Before echoing the assistant message, trim tool_calls to only the one we're responding to.
105
+ // Required by OpenAI-compatible providers: every tool_use id in the assistant message must
106
+ // have a matching tool_result on the next turn. Echoing unconsumed parallel tool_calls
107
+ // causes 400s on Anthropic-backed providers. Matches seeds/rewritable.html:3262.
108
+ const echoMessage = message.tool_calls.length > 1
109
+ ? { ...message, tool_calls: [call] }
110
+ : message;
111
+ messages.push(echoMessage);
112
+ messages.push({
113
+ role: 'tool',
114
+ tool_call_id: call.id,
115
+ content: JSON.stringify({
116
+ ok: false,
117
+ code: 'malformed_envelope',
118
+ message: `invalid JSON in tool arguments: ${e.message}`,
119
+ }),
120
+ });
121
+ continue;
122
+ }
123
+
124
+ messages.push(message);
125
+ return { envelope, toolName: call.function.name, messages };
126
+ }
127
+
128
+ throw new AgentError('no_envelope_after_retries', { retries: RETRY_BUDGET });
129
+ }
130
+
131
+ async function callBackend({ baseUrl, model, apiKey }, body) {
132
+ const headers = { 'Content-Type': 'application/json' };
133
+ if (apiKey) headers['Authorization'] = `Bearer ${apiKey}`;
134
+ const url = baseUrl.replace(/\/+$/, '') + '/chat/completions';
135
+ // Seed parity (seeds/rewritable.html openAiCompatChat caller in modify()):
136
+ // every request carries max_tokens: 32000 and tool_choice: 'auto'. The
137
+ // tool_choice default forces the model to call one of the provided tools
138
+ // rather than emitting plain text (which would trip our no_tool_call retry).
139
+ const res = await fetch(url, {
140
+ method: 'POST',
141
+ headers,
142
+ body: JSON.stringify({
143
+ model,
144
+ max_tokens: 32000,
145
+ tool_choice: 'auto',
146
+ ...body,
147
+ }),
148
+ });
149
+ if (!res.ok) {
150
+ let text = '';
151
+ try { text = await res.text(); } catch {}
152
+ throw new Error(`backend returned ${res.status}: ${text.slice(0, 200)}`);
153
+ }
154
+ return res.json();
155
+ }