predictable.go

  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}