1package loop
2
3import (
4 "context"
5 "encoding/json"
6 "fmt"
7 "os"
8 "strconv"
9 "strings"
10 "sync"
11 "time"
12
13 "shelley.exe.dev/llm"
14)
15
16// PredictableService is an LLM service that returns predictable responses for testing.
17//
18// To add new test patterns, update the Do() method directly by adding cases to the switch
19// statement or new prefix checks. Do not extend or wrap this service - modify it in place.
20// Available patterns include:
21// - "echo: <text>" - echoes the text back
22// - "bash: <command>" - triggers bash tool with command
23// - "think: <thoughts>" - returns response with extended thinking content
24// - "subagent: <slug> <prompt>" - triggers subagent tool
25// - "change_dir: <path>" - triggers change_dir tool
26// - "delay: <seconds>" - delays response by specified seconds
27// - See Do() method for complete list of supported patterns
28type PredictableService struct {
29 // TokenContextWindow size
30 tokenContextWindow int
31 mu sync.Mutex
32 // Recent requests for testing inspection
33 recentRequests []*llm.Request
34 responseDelay time.Duration
35}
36
37// NewPredictableService creates a new predictable LLM service
38func NewPredictableService() *PredictableService {
39 svc := &PredictableService{
40 tokenContextWindow: 200000,
41 }
42
43 if delayEnv := os.Getenv("PREDICTABLE_DELAY_MS"); delayEnv != "" {
44 if ms, err := strconv.Atoi(delayEnv); err == nil && ms > 0 {
45 svc.responseDelay = time.Duration(ms) * time.Millisecond
46 }
47 }
48
49 return svc
50}
51
52// TokenContextWindow returns the maximum token context window size
53func (s *PredictableService) TokenContextWindow() int {
54 return s.tokenContextWindow
55}
56
57// MaxImageDimension returns the maximum allowed image dimension.
58func (s *PredictableService) MaxImageDimension() int {
59 return 2000
60}
61
62// Do processes a request and returns a predictable response based on the input text
63func (s *PredictableService) Do(ctx context.Context, req *llm.Request) (*llm.Response, error) {
64 // Store request for testing inspection
65 s.mu.Lock()
66 delay := s.responseDelay
67 s.recentRequests = append(s.recentRequests, req)
68 // Keep only last 10 requests
69 if len(s.recentRequests) > 10 {
70 s.recentRequests = s.recentRequests[len(s.recentRequests)-10:]
71 }
72 s.mu.Unlock()
73
74 if delay > 0 {
75 select {
76 case <-time.After(delay):
77 case <-ctx.Done():
78 return nil, ctx.Err()
79 }
80 }
81
82 // Calculate input token count based on the request content
83 inputTokens := s.countRequestTokens(req)
84
85 // Extract the text content from the last user message
86 var inputText string
87 var hasToolResult bool
88 if len(req.Messages) > 0 {
89 lastMessage := req.Messages[len(req.Messages)-1]
90 if lastMessage.Role == llm.MessageRoleUser {
91 for _, content := range lastMessage.Content {
92 if content.Type == llm.ContentTypeText {
93 inputText = strings.TrimSpace(content.Text)
94 } else if content.Type == llm.ContentTypeToolResult {
95 hasToolResult = true
96 }
97 }
98 }
99 }
100
101 // If the message is purely a tool result (no text), acknowledge it and end turn
102 if hasToolResult && inputText == "" {
103 return s.makeResponse("Done.", inputTokens), nil
104 }
105
106 // Handle input using case statements
107 switch inputText {
108 case "hello":
109 return s.makeResponse("Well, hi there!", inputTokens), nil
110
111 case "Hello":
112 return s.makeResponse("Hello! I'm Shelley, your AI assistant. How can I help you today?", inputTokens), nil
113
114 case "Create an example":
115 return s.makeThinkingResponse("I'll create a simple example for you.", inputTokens), nil
116
117 case "screenshot":
118 // Trigger a screenshot of the current page
119 return s.makeScreenshotToolResponse("", inputTokens), nil
120
121 case "tool smorgasbord":
122 // Return a response with all tool types for testing
123 return s.makeToolSmorgasbordResponse(inputTokens), nil
124
125 case "echo: foo":
126 return s.makeResponse("foo", inputTokens), nil
127
128 case "patch fail":
129 // Trigger a patch that will fail (file doesn't exist)
130 return s.makePatchToolResponse("/nonexistent/file/that/does/not/exist.txt", inputTokens), nil
131
132 case "patch success":
133 // Trigger a patch that will succeed (using overwrite, which creates the file)
134 return s.makePatchToolResponseOverwrite("/tmp/test-patch-success.txt", inputTokens), nil
135
136 case "patch bad json":
137 // Trigger a patch with malformed JSON (simulates Anthropic sending invalid JSON)
138 return s.makeMalformedPatchToolResponse(inputTokens), nil
139
140 case "maxTokens":
141 // Simulate a max_tokens truncation
142 return s.makeMaxTokensResponse("This is a truncated response that was cut off mid-sentence because the output token limit was", inputTokens), nil
143
144 default:
145 // Handle pattern-based inputs
146 if strings.HasPrefix(inputText, "echo: ") {
147 text := strings.TrimPrefix(inputText, "echo: ")
148 return s.makeResponse(text, inputTokens), nil
149 }
150
151 if strings.HasPrefix(inputText, "bash: ") {
152 cmd := strings.TrimPrefix(inputText, "bash: ")
153 return s.makeBashToolResponse(cmd, inputTokens), nil
154 }
155
156 if strings.HasPrefix(inputText, "think: ") {
157 thoughts := strings.TrimPrefix(inputText, "think: ")
158 return s.makeThinkingResponse(thoughts, inputTokens), nil
159 }
160
161 if strings.HasPrefix(inputText, "patch: ") {
162 filePath := strings.TrimPrefix(inputText, "patch: ")
163 return s.makePatchToolResponse(filePath, inputTokens), nil
164 }
165
166 if strings.HasPrefix(inputText, "error: ") {
167 errorMsg := strings.TrimPrefix(inputText, "error: ")
168 return nil, fmt.Errorf("predictable error: %s", errorMsg)
169 }
170
171 if strings.HasPrefix(inputText, "screenshot: ") {
172 selector := strings.TrimSpace(strings.TrimPrefix(inputText, "screenshot: "))
173 return s.makeScreenshotToolResponse(selector, inputTokens), nil
174 }
175
176 if strings.HasPrefix(inputText, "subagent: ") {
177 // Format: "subagent: <slug> <prompt>"
178 parts := strings.SplitN(strings.TrimPrefix(inputText, "subagent: "), " ", 2)
179 slug := parts[0]
180 prompt := "do the task"
181 if len(parts) > 1 {
182 prompt = parts[1]
183 }
184 return s.makeSubagentToolResponse(slug, prompt, inputTokens), nil
185 }
186
187 if strings.HasPrefix(inputText, "change_dir: ") {
188 path := strings.TrimPrefix(inputText, "change_dir: ")
189 return s.makeChangeDirToolResponse(path, inputTokens), nil
190 }
191
192 if strings.HasPrefix(inputText, "delay: ") {
193 delayStr := strings.TrimPrefix(inputText, "delay: ")
194 delaySeconds, err := strconv.ParseFloat(delayStr, 64)
195 if err == nil && delaySeconds > 0 {
196 delayDuration := time.Duration(delaySeconds * float64(time.Second))
197 select {
198 case <-time.After(delayDuration):
199 case <-ctx.Done():
200 return nil, ctx.Err()
201 }
202 }
203 return s.makeResponse(fmt.Sprintf("Delayed for %s seconds", delayStr), inputTokens), nil
204 }
205
206 // Default response for undefined inputs
207 return s.makeResponse("edit predictable.go to add a response for that one...", inputTokens), nil
208 }
209}
210
211// makeMaxTokensResponse creates a response that simulates hitting max_tokens limit
212func (s *PredictableService) makeMaxTokensResponse(text string, inputTokens uint64) *llm.Response {
213 outputTokens := uint64(len(text) / 4)
214 if outputTokens == 0 {
215 outputTokens = 1
216 }
217 return &llm.Response{
218 ID: fmt.Sprintf("pred-%d", time.Now().UnixNano()),
219 Type: "message",
220 Role: llm.MessageRoleAssistant,
221 Model: "predictable-v1",
222 Content: []llm.Content{
223 {Type: llm.ContentTypeText, Text: text},
224 },
225 StopReason: llm.StopReasonMaxTokens,
226 Usage: llm.Usage{
227 InputTokens: inputTokens,
228 OutputTokens: outputTokens,
229 CostUSD: 0.001,
230 },
231 }
232}
233
234// makeResponse creates a simple text response
235func (s *PredictableService) makeResponse(text string, inputTokens uint64) *llm.Response {
236 outputTokens := uint64(len(text) / 4) // ~4 chars per token
237 if outputTokens == 0 {
238 outputTokens = 1
239 }
240 return &llm.Response{
241 ID: fmt.Sprintf("pred-%d", time.Now().UnixNano()),
242 Type: "message",
243 Role: llm.MessageRoleAssistant,
244 Model: "predictable-v1",
245 Content: []llm.Content{
246 {Type: llm.ContentTypeText, Text: text},
247 },
248 StopReason: llm.StopReasonStopSequence,
249 Usage: llm.Usage{
250 InputTokens: inputTokens,
251 OutputTokens: outputTokens,
252 CostUSD: 0.001,
253 },
254 }
255}
256
257// makeBashToolResponse creates a response that calls the bash tool
258func (s *PredictableService) makeBashToolResponse(command string, inputTokens uint64) *llm.Response {
259 // Properly marshal the command to avoid JSON escaping issues
260 toolInputData := map[string]string{"command": command}
261 toolInputBytes, _ := json.Marshal(toolInputData)
262 toolInput := json.RawMessage(toolInputBytes)
263 responseText := fmt.Sprintf("I'll run the command: %s", command)
264 outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
265 if outputTokens == 0 {
266 outputTokens = 1
267 }
268 return &llm.Response{
269 ID: fmt.Sprintf("pred-bash-%d", time.Now().UnixNano()),
270 Type: "message",
271 Role: llm.MessageRoleAssistant,
272 Model: "predictable-v1",
273 Content: []llm.Content{
274 {Type: llm.ContentTypeText, Text: responseText},
275 {
276 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
277 Type: llm.ContentTypeToolUse,
278 ToolName: "bash",
279 ToolInput: toolInput,
280 },
281 },
282 StopReason: llm.StopReasonToolUse,
283 Usage: llm.Usage{
284 InputTokens: inputTokens,
285 OutputTokens: outputTokens,
286 CostUSD: 0.002,
287 },
288 }
289}
290
291// makeThinkingResponse creates a response with extended thinking content
292func (s *PredictableService) makeThinkingResponse(thoughts string, inputTokens uint64) *llm.Response {
293 responseText := "I've considered my approach."
294 outputTokens := uint64(len(responseText)/4 + len(thoughts)/4)
295 if outputTokens == 0 {
296 outputTokens = 1
297 }
298 return &llm.Response{
299 ID: fmt.Sprintf("pred-thinking-%d", time.Now().UnixNano()),
300 Type: "message",
301 Role: llm.MessageRoleAssistant,
302 Model: "predictable-v1",
303 Content: []llm.Content{
304 {Type: llm.ContentTypeThinking, Thinking: thoughts},
305 {Type: llm.ContentTypeText, Text: responseText},
306 },
307 StopReason: llm.StopReasonEndTurn,
308 Usage: llm.Usage{
309 InputTokens: inputTokens,
310 OutputTokens: outputTokens,
311 CostUSD: 0.002,
312 },
313 }
314}
315
316// makePatchToolResponse creates a response that calls the patch tool
317func (s *PredictableService) makePatchToolResponse(filePath string, inputTokens uint64) *llm.Response {
318 // Properly marshal the patch data to avoid JSON escaping issues
319 toolInputData := map[string]interface{}{
320 "path": filePath,
321 "patches": []map[string]string{
322 {
323 "operation": "replace",
324 "oldText": "example",
325 "newText": "updated example",
326 },
327 },
328 }
329 toolInputBytes, _ := json.Marshal(toolInputData)
330 toolInput := json.RawMessage(toolInputBytes)
331 responseText := fmt.Sprintf("I'll patch the file: %s", filePath)
332 outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
333 if outputTokens == 0 {
334 outputTokens = 1
335 }
336 return &llm.Response{
337 ID: fmt.Sprintf("pred-patch-%d", time.Now().UnixNano()),
338 Type: "message",
339 Role: llm.MessageRoleAssistant,
340 Model: "predictable-v1",
341 Content: []llm.Content{
342 {Type: llm.ContentTypeText, Text: responseText},
343 {
344 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
345 Type: llm.ContentTypeToolUse,
346 ToolName: "patch",
347 ToolInput: toolInput,
348 },
349 },
350 StopReason: llm.StopReasonToolUse,
351 Usage: llm.Usage{
352 InputTokens: inputTokens,
353 OutputTokens: outputTokens,
354 CostUSD: 0.003,
355 },
356 }
357}
358
359// makePatchToolResponseOverwrite creates a response that uses overwrite operation (always succeeds)
360func (s *PredictableService) makePatchToolResponseOverwrite(filePath string, inputTokens uint64) *llm.Response {
361 toolInputData := map[string]interface{}{
362 "path": filePath,
363 "patches": []map[string]string{
364 {
365 "operation": "overwrite",
366 "newText": "This is the new content of the file.\nLine 2\nLine 3\n",
367 },
368 },
369 }
370 toolInputBytes, _ := json.Marshal(toolInputData)
371 toolInput := json.RawMessage(toolInputBytes)
372 responseText := fmt.Sprintf("I'll create/overwrite the file: %s", filePath)
373 outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
374 if outputTokens == 0 {
375 outputTokens = 1
376 }
377 return &llm.Response{
378 ID: fmt.Sprintf("pred-patch-overwrite-%d", time.Now().UnixNano()),
379 Type: "message",
380 Role: llm.MessageRoleAssistant,
381 Model: "predictable-v1",
382 Content: []llm.Content{
383 {Type: llm.ContentTypeText, Text: responseText},
384 {
385 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
386 Type: llm.ContentTypeToolUse,
387 ToolName: "patch",
388 ToolInput: toolInput,
389 },
390 },
391 StopReason: llm.StopReasonToolUse,
392 Usage: llm.Usage{
393 InputTokens: inputTokens,
394 OutputTokens: outputTokens,
395 CostUSD: 0.0,
396 },
397 }
398}
399
400// makeMalformedPatchToolResponse creates a response with malformed JSON that will fail to parse
401// This simulates when Anthropic sends back invalid JSON in the tool input
402func (s *PredictableService) makeMalformedPatchToolResponse(inputTokens uint64) *llm.Response {
403 // This malformed JSON has a string where an object is expected (patch field)
404 // Mimics the error: "cannot unmarshal string into Go struct field PatchInputOneSingular.patch"
405 malformedJSON := `{"path":"/home/agent/example.css","patch":"<parameter name=\"operation\">replace","oldText":".example {\n color: red;\n}","newText":".example {\n color: blue;\n}"}`
406 toolInput := json.RawMessage(malformedJSON)
407 return &llm.Response{
408 ID: fmt.Sprintf("pred-patch-malformed-%d", time.Now().UnixNano()),
409 Type: "message",
410 Role: llm.MessageRoleAssistant,
411 Model: "predictable-v1",
412 Content: []llm.Content{
413 {Type: llm.ContentTypeText, Text: "I'll patch the file with the changes."},
414 {
415 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
416 Type: llm.ContentTypeToolUse,
417 ToolName: "patch",
418 ToolInput: toolInput,
419 },
420 },
421 StopReason: llm.StopReasonToolUse,
422 Usage: llm.Usage{
423 InputTokens: inputTokens,
424 OutputTokens: 50,
425 CostUSD: 0.003,
426 },
427 }
428}
429
430// GetRecentRequests returns the recent requests made to this service
431func (s *PredictableService) GetRecentRequests() []*llm.Request {
432 s.mu.Lock()
433 defer s.mu.Unlock()
434
435 if len(s.recentRequests) == 0 {
436 return nil
437 }
438
439 requests := make([]*llm.Request, len(s.recentRequests))
440 copy(requests, s.recentRequests)
441 return requests
442}
443
444// GetLastRequest returns the most recent request, or nil if none
445func (s *PredictableService) GetLastRequest() *llm.Request {
446 s.mu.Lock()
447 defer s.mu.Unlock()
448
449 if len(s.recentRequests) == 0 {
450 return nil
451 }
452 return s.recentRequests[len(s.recentRequests)-1]
453}
454
455// ClearRequests clears the request history
456func (s *PredictableService) ClearRequests() {
457 s.mu.Lock()
458 defer s.mu.Unlock()
459
460 s.recentRequests = nil
461}
462
463// countRequestTokens estimates token count based on character count.
464// Uses a simple ~4 chars per token approximation.
465func (s *PredictableService) countRequestTokens(req *llm.Request) uint64 {
466 var totalChars int
467
468 // Count system prompt characters
469 for _, sys := range req.System {
470 totalChars += len(sys.Text)
471 }
472
473 // Count message characters
474 for _, msg := range req.Messages {
475 for _, content := range msg.Content {
476 switch content.Type {
477 case llm.ContentTypeText:
478 totalChars += len(content.Text)
479 case llm.ContentTypeToolUse:
480 totalChars += len(content.ToolName)
481 totalChars += len(content.ToolInput)
482 case llm.ContentTypeToolResult:
483 for _, result := range content.ToolResult {
484 if result.Type == llm.ContentTypeText {
485 totalChars += len(result.Text)
486 }
487 }
488 }
489 }
490 }
491
492 // Count tool definitions
493 for _, tool := range req.Tools {
494 totalChars += len(tool.Name)
495 totalChars += len(tool.Description)
496 totalChars += len(tool.InputSchema)
497 }
498
499 // ~4 chars per token is a rough approximation
500 return uint64(totalChars / 4)
501}
502
503// makeScreenshotToolResponse creates a response that calls the screenshot tool
504func (s *PredictableService) makeScreenshotToolResponse(selector string, inputTokens uint64) *llm.Response {
505 toolInputData := map[string]any{}
506 if selector != "" {
507 toolInputData["selector"] = selector
508 }
509 toolInputBytes, _ := json.Marshal(toolInputData)
510 toolInput := json.RawMessage(toolInputBytes)
511 responseText := "Taking a screenshot..."
512 outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
513 if outputTokens == 0 {
514 outputTokens = 1
515 }
516 return &llm.Response{
517 ID: fmt.Sprintf("pred-screenshot-%d", time.Now().UnixNano()),
518 Type: "message",
519 Role: llm.MessageRoleAssistant,
520 Model: "predictable-v1",
521 Content: []llm.Content{
522 {Type: llm.ContentTypeText, Text: responseText},
523 {
524 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
525 Type: llm.ContentTypeToolUse,
526 ToolName: "browser_take_screenshot",
527 ToolInput: toolInput,
528 },
529 },
530 StopReason: llm.StopReasonToolUse,
531 Usage: llm.Usage{
532 InputTokens: inputTokens,
533 OutputTokens: outputTokens,
534 CostUSD: 0.0,
535 },
536 }
537}
538
539// makeChangeDirToolResponse creates a response that calls the change_dir tool
540func (s *PredictableService) makeChangeDirToolResponse(path string, inputTokens uint64) *llm.Response {
541 toolInputData := map[string]string{"path": path}
542 toolInputBytes, _ := json.Marshal(toolInputData)
543 toolInput := json.RawMessage(toolInputBytes)
544 responseText := fmt.Sprintf("I'll change to directory: %s", path)
545 outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
546 if outputTokens == 0 {
547 outputTokens = 1
548 }
549 return &llm.Response{
550 ID: fmt.Sprintf("pred-change_dir-%d", time.Now().UnixNano()),
551 Type: "message",
552 Role: llm.MessageRoleAssistant,
553 Model: "predictable-v1",
554 Content: []llm.Content{
555 {Type: llm.ContentTypeText, Text: responseText},
556 {
557 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
558 Type: llm.ContentTypeToolUse,
559 ToolName: "change_dir",
560 ToolInput: toolInput,
561 },
562 },
563 StopReason: llm.StopReasonToolUse,
564 Usage: llm.Usage{
565 InputTokens: inputTokens,
566 OutputTokens: outputTokens,
567 CostUSD: 0.001,
568 },
569 }
570}
571
572func (s *PredictableService) makeSubagentToolResponse(slug, prompt string, inputTokens uint64) *llm.Response {
573 toolInputData := map[string]any{
574 "slug": slug,
575 "prompt": prompt,
576 }
577 toolInputBytes, _ := json.Marshal(toolInputData)
578 toolInput := json.RawMessage(toolInputBytes)
579 responseText := fmt.Sprintf("Delegating to subagent '%s'...", slug)
580 outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
581 if outputTokens == 0 {
582 outputTokens = 1
583 }
584 return &llm.Response{
585 ID: fmt.Sprintf("pred-subagent-%d", time.Now().UnixNano()),
586 Type: "message",
587 Role: llm.MessageRoleAssistant,
588 Model: "predictable-v1",
589 Content: []llm.Content{
590 {Type: llm.ContentTypeText, Text: responseText},
591 {
592 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
593 Type: llm.ContentTypeToolUse,
594 ToolName: "subagent",
595 ToolInput: toolInput,
596 },
597 },
598 StopReason: llm.StopReasonToolUse,
599 Usage: llm.Usage{
600 InputTokens: inputTokens,
601 OutputTokens: outputTokens,
602 CostUSD: 0.0,
603 },
604 }
605}
606
607// makeToolSmorgasbordResponse creates a response that uses all available tool types
608func (s *PredictableService) makeToolSmorgasbordResponse(inputTokens uint64) *llm.Response {
609 baseNano := time.Now().UnixNano()
610 content := []llm.Content{
611 {Type: llm.ContentTypeText, Text: "Here's a sample of all the tools:"},
612 }
613
614 // bash tool
615 bashInput, _ := json.Marshal(map[string]string{"command": "echo 'hello from bash'"})
616 content = append(content, llm.Content{
617 ID: fmt.Sprintf("tool_bash_%d", baseNano%1000),
618 Type: llm.ContentTypeToolUse,
619 ToolName: "bash",
620 ToolInput: json.RawMessage(bashInput),
621 })
622
623 // extended thinking content (not a tool)
624 content = append(content, llm.Content{
625 Type: llm.ContentTypeThinking,
626 Thinking: "I'm thinking about the best approach for this task. Let me consider all the options available.",
627 })
628
629 // patch tool
630 patchInput, _ := json.Marshal(map[string]interface{}{
631 "path": "/tmp/example.txt",
632 "patches": []map[string]string{
633 {"operation": "replace", "oldText": "foo", "newText": "bar"},
634 },
635 })
636 content = append(content, llm.Content{
637 ID: fmt.Sprintf("tool_patch_%d", (baseNano+2)%1000),
638 Type: llm.ContentTypeToolUse,
639 ToolName: "patch",
640 ToolInput: json.RawMessage(patchInput),
641 })
642
643 // screenshot tool
644 screenshotInput, _ := json.Marshal(map[string]string{})
645 content = append(content, llm.Content{
646 ID: fmt.Sprintf("tool_screenshot_%d", (baseNano+3)%1000),
647 Type: llm.ContentTypeToolUse,
648 ToolName: "browser_take_screenshot",
649 ToolInput: json.RawMessage(screenshotInput),
650 })
651
652 // keyword_search tool
653 keywordInput, _ := json.Marshal(map[string]interface{}{
654 "query": "find all references",
655 "search_terms": []string{"reference", "example"},
656 })
657 content = append(content, llm.Content{
658 ID: fmt.Sprintf("tool_keyword_%d", (baseNano+4)%1000),
659 Type: llm.ContentTypeToolUse,
660 ToolName: "keyword_search",
661 ToolInput: json.RawMessage(keywordInput),
662 })
663
664 // browser_navigate tool
665 navigateInput, _ := json.Marshal(map[string]string{"url": "https://example.com"})
666 content = append(content, llm.Content{
667 ID: fmt.Sprintf("tool_navigate_%d", (baseNano+5)%1000),
668 Type: llm.ContentTypeToolUse,
669 ToolName: "browser_navigate",
670 ToolInput: json.RawMessage(navigateInput),
671 })
672
673 // browser_eval tool
674 evalInput, _ := json.Marshal(map[string]string{"expression": "document.title"})
675 content = append(content, llm.Content{
676 ID: fmt.Sprintf("tool_eval_%d", (baseNano+6)%1000),
677 Type: llm.ContentTypeToolUse,
678 ToolName: "browser_eval",
679 ToolInput: json.RawMessage(evalInput),
680 })
681
682 // read_image tool
683 readImageInput, _ := json.Marshal(map[string]string{"path": "/tmp/image.png"})
684 content = append(content, llm.Content{
685 ID: fmt.Sprintf("tool_readimg_%d", (baseNano+7)%1000),
686 Type: llm.ContentTypeToolUse,
687 ToolName: "read_image",
688 ToolInput: json.RawMessage(readImageInput),
689 })
690
691 // browser_recent_console_logs tool
692 consoleInput, _ := json.Marshal(map[string]string{})
693 content = append(content, llm.Content{
694 ID: fmt.Sprintf("tool_console_%d", (baseNano+8)%1000),
695 Type: llm.ContentTypeToolUse,
696 ToolName: "browser_recent_console_logs",
697 ToolInput: json.RawMessage(consoleInput),
698 })
699
700 return &llm.Response{
701 ID: fmt.Sprintf("pred-smorgasbord-%d", baseNano),
702 Type: "message",
703 Role: llm.MessageRoleAssistant,
704 Model: "predictable-v1",
705 Content: content,
706 StopReason: llm.StopReasonToolUse,
707 Usage: llm.Usage{
708 InputTokens: inputTokens,
709 OutputTokens: 200,
710 CostUSD: 0.01,
711 },
712 }
713}