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>" - triggers think tool
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.makeThinkToolResponse("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.makeThinkToolResponse(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// makeThinkToolResponse creates a response that calls the think tool
292func (s *PredictableService) makeThinkToolResponse(thoughts string, inputTokens uint64) *llm.Response {
293 // Properly marshal the thoughts to avoid JSON escaping issues
294 toolInputData := map[string]string{"thoughts": thoughts}
295 toolInputBytes, _ := json.Marshal(toolInputData)
296 toolInput := json.RawMessage(toolInputBytes)
297 responseText := "Let me think about this."
298 outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
299 if outputTokens == 0 {
300 outputTokens = 1
301 }
302 return &llm.Response{
303 ID: fmt.Sprintf("pred-think-%d", time.Now().UnixNano()),
304 Type: "message",
305 Role: llm.MessageRoleAssistant,
306 Model: "predictable-v1",
307 Content: []llm.Content{
308 {Type: llm.ContentTypeText, Text: responseText},
309 {
310 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
311 Type: llm.ContentTypeToolUse,
312 ToolName: "think",
313 ToolInput: toolInput,
314 },
315 },
316 StopReason: llm.StopReasonToolUse,
317 Usage: llm.Usage{
318 InputTokens: inputTokens,
319 OutputTokens: outputTokens,
320 CostUSD: 0.002,
321 },
322 }
323}
324
325// makePatchToolResponse creates a response that calls the patch tool
326func (s *PredictableService) makePatchToolResponse(filePath string, inputTokens uint64) *llm.Response {
327 // Properly marshal the patch data to avoid JSON escaping issues
328 toolInputData := map[string]interface{}{
329 "path": filePath,
330 "patches": []map[string]string{
331 {
332 "operation": "replace",
333 "oldText": "example",
334 "newText": "updated example",
335 },
336 },
337 }
338 toolInputBytes, _ := json.Marshal(toolInputData)
339 toolInput := json.RawMessage(toolInputBytes)
340 responseText := fmt.Sprintf("I'll patch the file: %s", filePath)
341 outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
342 if outputTokens == 0 {
343 outputTokens = 1
344 }
345 return &llm.Response{
346 ID: fmt.Sprintf("pred-patch-%d", time.Now().UnixNano()),
347 Type: "message",
348 Role: llm.MessageRoleAssistant,
349 Model: "predictable-v1",
350 Content: []llm.Content{
351 {Type: llm.ContentTypeText, Text: responseText},
352 {
353 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
354 Type: llm.ContentTypeToolUse,
355 ToolName: "patch",
356 ToolInput: toolInput,
357 },
358 },
359 StopReason: llm.StopReasonToolUse,
360 Usage: llm.Usage{
361 InputTokens: inputTokens,
362 OutputTokens: outputTokens,
363 CostUSD: 0.003,
364 },
365 }
366}
367
368// makePatchToolResponseOverwrite creates a response that uses overwrite operation (always succeeds)
369func (s *PredictableService) makePatchToolResponseOverwrite(filePath string, inputTokens uint64) *llm.Response {
370 toolInputData := map[string]interface{}{
371 "path": filePath,
372 "patches": []map[string]string{
373 {
374 "operation": "overwrite",
375 "newText": "This is the new content of the file.\nLine 2\nLine 3\n",
376 },
377 },
378 }
379 toolInputBytes, _ := json.Marshal(toolInputData)
380 toolInput := json.RawMessage(toolInputBytes)
381 responseText := fmt.Sprintf("I'll create/overwrite the file: %s", filePath)
382 outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
383 if outputTokens == 0 {
384 outputTokens = 1
385 }
386 return &llm.Response{
387 ID: fmt.Sprintf("pred-patch-overwrite-%d", time.Now().UnixNano()),
388 Type: "message",
389 Role: llm.MessageRoleAssistant,
390 Model: "predictable-v1",
391 Content: []llm.Content{
392 {Type: llm.ContentTypeText, Text: responseText},
393 {
394 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
395 Type: llm.ContentTypeToolUse,
396 ToolName: "patch",
397 ToolInput: toolInput,
398 },
399 },
400 StopReason: llm.StopReasonToolUse,
401 Usage: llm.Usage{
402 InputTokens: inputTokens,
403 OutputTokens: outputTokens,
404 CostUSD: 0.0,
405 },
406 }
407}
408
409// makeMalformedPatchToolResponse creates a response with malformed JSON that will fail to parse
410// This simulates when Anthropic sends back invalid JSON in the tool input
411func (s *PredictableService) makeMalformedPatchToolResponse(inputTokens uint64) *llm.Response {
412 // This malformed JSON has a string where an object is expected (patch field)
413 // Mimics the error: "cannot unmarshal string into Go struct field PatchInputOneSingular.patch"
414 malformedJSON := `{"path":"/home/agent/example.css","patch":"<parameter name=\"operation\">replace","oldText":".example {\n color: red;\n}","newText":".example {\n color: blue;\n}"}`
415 toolInput := json.RawMessage(malformedJSON)
416 return &llm.Response{
417 ID: fmt.Sprintf("pred-patch-malformed-%d", time.Now().UnixNano()),
418 Type: "message",
419 Role: llm.MessageRoleAssistant,
420 Model: "predictable-v1",
421 Content: []llm.Content{
422 {Type: llm.ContentTypeText, Text: "I'll patch the file with the changes."},
423 {
424 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
425 Type: llm.ContentTypeToolUse,
426 ToolName: "patch",
427 ToolInput: toolInput,
428 },
429 },
430 StopReason: llm.StopReasonToolUse,
431 Usage: llm.Usage{
432 InputTokens: inputTokens,
433 OutputTokens: 50,
434 CostUSD: 0.003,
435 },
436 }
437}
438
439// GetRecentRequests returns the recent requests made to this service
440func (s *PredictableService) GetRecentRequests() []*llm.Request {
441 s.mu.Lock()
442 defer s.mu.Unlock()
443
444 if len(s.recentRequests) == 0 {
445 return nil
446 }
447
448 requests := make([]*llm.Request, len(s.recentRequests))
449 copy(requests, s.recentRequests)
450 return requests
451}
452
453// GetLastRequest returns the most recent request, or nil if none
454func (s *PredictableService) GetLastRequest() *llm.Request {
455 s.mu.Lock()
456 defer s.mu.Unlock()
457
458 if len(s.recentRequests) == 0 {
459 return nil
460 }
461 return s.recentRequests[len(s.recentRequests)-1]
462}
463
464// ClearRequests clears the request history
465func (s *PredictableService) ClearRequests() {
466 s.mu.Lock()
467 defer s.mu.Unlock()
468
469 s.recentRequests = nil
470}
471
472// countRequestTokens estimates token count based on character count.
473// Uses a simple ~4 chars per token approximation.
474func (s *PredictableService) countRequestTokens(req *llm.Request) uint64 {
475 var totalChars int
476
477 // Count system prompt characters
478 for _, sys := range req.System {
479 totalChars += len(sys.Text)
480 }
481
482 // Count message characters
483 for _, msg := range req.Messages {
484 for _, content := range msg.Content {
485 switch content.Type {
486 case llm.ContentTypeText:
487 totalChars += len(content.Text)
488 case llm.ContentTypeToolUse:
489 totalChars += len(content.ToolName)
490 totalChars += len(content.ToolInput)
491 case llm.ContentTypeToolResult:
492 for _, result := range content.ToolResult {
493 if result.Type == llm.ContentTypeText {
494 totalChars += len(result.Text)
495 }
496 }
497 }
498 }
499 }
500
501 // Count tool definitions
502 for _, tool := range req.Tools {
503 totalChars += len(tool.Name)
504 totalChars += len(tool.Description)
505 totalChars += len(tool.InputSchema)
506 }
507
508 // ~4 chars per token is a rough approximation
509 return uint64(totalChars / 4)
510}
511
512// makeScreenshotToolResponse creates a response that calls the screenshot tool
513func (s *PredictableService) makeScreenshotToolResponse(selector string, inputTokens uint64) *llm.Response {
514 toolInputData := map[string]any{}
515 if selector != "" {
516 toolInputData["selector"] = selector
517 }
518 toolInputBytes, _ := json.Marshal(toolInputData)
519 toolInput := json.RawMessage(toolInputBytes)
520 responseText := "Taking a screenshot..."
521 outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
522 if outputTokens == 0 {
523 outputTokens = 1
524 }
525 return &llm.Response{
526 ID: fmt.Sprintf("pred-screenshot-%d", time.Now().UnixNano()),
527 Type: "message",
528 Role: llm.MessageRoleAssistant,
529 Model: "predictable-v1",
530 Content: []llm.Content{
531 {Type: llm.ContentTypeText, Text: responseText},
532 {
533 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
534 Type: llm.ContentTypeToolUse,
535 ToolName: "browser_take_screenshot",
536 ToolInput: toolInput,
537 },
538 },
539 StopReason: llm.StopReasonToolUse,
540 Usage: llm.Usage{
541 InputTokens: inputTokens,
542 OutputTokens: outputTokens,
543 CostUSD: 0.0,
544 },
545 }
546}
547
548// makeChangeDirToolResponse creates a response that calls the change_dir tool
549func (s *PredictableService) makeChangeDirToolResponse(path string, inputTokens uint64) *llm.Response {
550 toolInputData := map[string]string{"path": path}
551 toolInputBytes, _ := json.Marshal(toolInputData)
552 toolInput := json.RawMessage(toolInputBytes)
553 responseText := fmt.Sprintf("I'll change to directory: %s", path)
554 outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
555 if outputTokens == 0 {
556 outputTokens = 1
557 }
558 return &llm.Response{
559 ID: fmt.Sprintf("pred-change_dir-%d", time.Now().UnixNano()),
560 Type: "message",
561 Role: llm.MessageRoleAssistant,
562 Model: "predictable-v1",
563 Content: []llm.Content{
564 {Type: llm.ContentTypeText, Text: responseText},
565 {
566 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
567 Type: llm.ContentTypeToolUse,
568 ToolName: "change_dir",
569 ToolInput: toolInput,
570 },
571 },
572 StopReason: llm.StopReasonToolUse,
573 Usage: llm.Usage{
574 InputTokens: inputTokens,
575 OutputTokens: outputTokens,
576 CostUSD: 0.001,
577 },
578 }
579}
580
581func (s *PredictableService) makeSubagentToolResponse(slug, prompt string, inputTokens uint64) *llm.Response {
582 toolInputData := map[string]any{
583 "slug": slug,
584 "prompt": prompt,
585 }
586 toolInputBytes, _ := json.Marshal(toolInputData)
587 toolInput := json.RawMessage(toolInputBytes)
588 responseText := fmt.Sprintf("Delegating to subagent '%s'...", slug)
589 outputTokens := uint64(len(responseText)/4 + len(toolInputBytes)/4)
590 if outputTokens == 0 {
591 outputTokens = 1
592 }
593 return &llm.Response{
594 ID: fmt.Sprintf("pred-subagent-%d", time.Now().UnixNano()),
595 Type: "message",
596 Role: llm.MessageRoleAssistant,
597 Model: "predictable-v1",
598 Content: []llm.Content{
599 {Type: llm.ContentTypeText, Text: responseText},
600 {
601 ID: fmt.Sprintf("tool_%d", time.Now().UnixNano()%1000),
602 Type: llm.ContentTypeToolUse,
603 ToolName: "subagent",
604 ToolInput: toolInput,
605 },
606 },
607 StopReason: llm.StopReasonToolUse,
608 Usage: llm.Usage{
609 InputTokens: inputTokens,
610 OutputTokens: outputTokens,
611 CostUSD: 0.0,
612 },
613 }
614}
615
616// makeToolSmorgasbordResponse creates a response that uses all available tool types
617func (s *PredictableService) makeToolSmorgasbordResponse(inputTokens uint64) *llm.Response {
618 baseNano := time.Now().UnixNano()
619 content := []llm.Content{
620 {Type: llm.ContentTypeText, Text: "Here's a sample of all the tools:"},
621 }
622
623 // bash tool
624 bashInput, _ := json.Marshal(map[string]string{"command": "echo 'hello from bash'"})
625 content = append(content, llm.Content{
626 ID: fmt.Sprintf("tool_bash_%d", baseNano%1000),
627 Type: llm.ContentTypeToolUse,
628 ToolName: "bash",
629 ToolInput: json.RawMessage(bashInput),
630 })
631
632 // think tool
633 thinkInput, _ := json.Marshal(map[string]string{"thoughts": "I'm thinking about the best approach for this task. Let me consider all the options available."})
634 content = append(content, llm.Content{
635 ID: fmt.Sprintf("tool_think_%d", (baseNano+1)%1000),
636 Type: llm.ContentTypeToolUse,
637 ToolName: "think",
638 ToolInput: json.RawMessage(thinkInput),
639 })
640
641 // patch tool
642 patchInput, _ := json.Marshal(map[string]interface{}{
643 "path": "/tmp/example.txt",
644 "patches": []map[string]string{
645 {"operation": "replace", "oldText": "foo", "newText": "bar"},
646 },
647 })
648 content = append(content, llm.Content{
649 ID: fmt.Sprintf("tool_patch_%d", (baseNano+2)%1000),
650 Type: llm.ContentTypeToolUse,
651 ToolName: "patch",
652 ToolInput: json.RawMessage(patchInput),
653 })
654
655 // screenshot tool
656 screenshotInput, _ := json.Marshal(map[string]string{})
657 content = append(content, llm.Content{
658 ID: fmt.Sprintf("tool_screenshot_%d", (baseNano+3)%1000),
659 Type: llm.ContentTypeToolUse,
660 ToolName: "browser_take_screenshot",
661 ToolInput: json.RawMessage(screenshotInput),
662 })
663
664 // keyword_search tool
665 keywordInput, _ := json.Marshal(map[string]interface{}{
666 "query": "find all references",
667 "search_terms": []string{"reference", "example"},
668 })
669 content = append(content, llm.Content{
670 ID: fmt.Sprintf("tool_keyword_%d", (baseNano+4)%1000),
671 Type: llm.ContentTypeToolUse,
672 ToolName: "keyword_search",
673 ToolInput: json.RawMessage(keywordInput),
674 })
675
676 // browser_navigate tool
677 navigateInput, _ := json.Marshal(map[string]string{"url": "https://example.com"})
678 content = append(content, llm.Content{
679 ID: fmt.Sprintf("tool_navigate_%d", (baseNano+5)%1000),
680 Type: llm.ContentTypeToolUse,
681 ToolName: "browser_navigate",
682 ToolInput: json.RawMessage(navigateInput),
683 })
684
685 // browser_eval tool
686 evalInput, _ := json.Marshal(map[string]string{"expression": "document.title"})
687 content = append(content, llm.Content{
688 ID: fmt.Sprintf("tool_eval_%d", (baseNano+6)%1000),
689 Type: llm.ContentTypeToolUse,
690 ToolName: "browser_eval",
691 ToolInput: json.RawMessage(evalInput),
692 })
693
694 // read_image tool
695 readImageInput, _ := json.Marshal(map[string]string{"path": "/tmp/image.png"})
696 content = append(content, llm.Content{
697 ID: fmt.Sprintf("tool_readimg_%d", (baseNano+7)%1000),
698 Type: llm.ContentTypeToolUse,
699 ToolName: "read_image",
700 ToolInput: json.RawMessage(readImageInput),
701 })
702
703 // browser_recent_console_logs tool
704 consoleInput, _ := json.Marshal(map[string]string{})
705 content = append(content, llm.Content{
706 ID: fmt.Sprintf("tool_console_%d", (baseNano+8)%1000),
707 Type: llm.ContentTypeToolUse,
708 ToolName: "browser_recent_console_logs",
709 ToolInput: json.RawMessage(consoleInput),
710 })
711
712 return &llm.Response{
713 ID: fmt.Sprintf("pred-smorgasbord-%d", baseNano),
714 Type: "message",
715 Role: llm.MessageRoleAssistant,
716 Model: "predictable-v1",
717 Content: content,
718 StopReason: llm.StopReasonToolUse,
719 Usage: llm.Usage{
720 InputTokens: inputTokens,
721 OutputTokens: 200,
722 CostUSD: 0.01,
723 },
724 }
725}