Skip to content

Commit 06b2fef

Browse files
feat(agent): add per-tool execution config (#31)
1 parent d891e1d commit 06b2fef

10 files changed

Lines changed: 283 additions & 11 deletions

File tree

README.md

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -110,6 +110,10 @@ context:
110110
tools:
111111
- file_list
112112
- file_read
113+
tool_config:
114+
file_read:
115+
timeout: 5s
116+
result_cap: 8000
113117
```
114118
115119
If `provider` is omitted, Vikusha infers Anthropic for models beginning with `claude`; otherwise it uses OpenAI-compatible chat completions. The default env vars are `ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, `OPENROUTER_API_KEY`, and `GROQ_API_KEY`, depending on the provider.

core/agent/agent.go

Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ type Agent struct {
2323
systemPrompt string
2424
provider llm.Provider
2525
tools *tool.Registry
26+
toolConfig map[string]ToolConfig
2627
memory memory.Memory
2728
toolResultCap int
2829
historyBudget int
@@ -49,12 +50,18 @@ type Options struct {
4950
SystemPrompt string
5051
Provider llm.Provider
5152
Tools *tool.Registry
53+
ToolConfig map[string]ToolConfig
5254
Memory memory.Memory
5355
ToolResultCap int
5456
HistoryTokenBudget int
5557
Logger TurnLogger
5658
}
5759

60+
type ToolConfig struct {
61+
Timeout time.Duration
62+
ResultCap int
63+
}
64+
5865
type TurnLogger interface {
5966
LogTurn(ctx context.Context, event TurnEvent)
6067
}
@@ -101,6 +108,7 @@ func New(opts Options) (*Agent, error) {
101108
systemPrompt: opts.SystemPrompt,
102109
provider: opts.Provider,
103110
tools: opts.Tools,
111+
toolConfig: cloneToolConfig(opts.ToolConfig),
104112
memory: opts.Memory,
105113
toolResultCap: opts.ToolResultCap,
106114
historyBudget: opts.HistoryTokenBudget,
@@ -111,6 +119,17 @@ func New(opts Options) (*Agent, error) {
111119
}, nil
112120
}
113121

122+
func cloneToolConfig(in map[string]ToolConfig) map[string]ToolConfig {
123+
if len(in) == 0 {
124+
return nil
125+
}
126+
out := make(map[string]ToolConfig, len(in))
127+
for name, cfg := range in {
128+
out[name] = cfg
129+
}
130+
return out
131+
}
132+
114133
func (a *Agent) Name() string { return a.name }
115134

116135
func (a *Agent) Cancel(userID string) bool {

core/agent/agent_test.go

Lines changed: 90 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,96 @@ func TestCapToolResultTruncatesLongOutput(t *testing.T) {
125125
}
126126
}
127127

128+
type outputTool struct {
129+
name string
130+
output string
131+
}
132+
133+
func (t outputTool) Name() string { return t.name }
134+
135+
func (t outputTool) Description() string { return "output" }
136+
137+
func (t outputTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
138+
139+
func (t outputTool) Run(ctx context.Context, input json.RawMessage) (string, error) {
140+
return t.output, nil
141+
}
142+
143+
func TestRunToolUsesPerToolResultCap(t *testing.T) {
144+
reg := tool.NewRegistry()
145+
reg.Register(outputTool{name: "long", output: "hello world"})
146+
a, err := New(Options{
147+
Name: "test",
148+
Model: "test-model",
149+
Provider: staticProvider{},
150+
Tools: reg,
151+
ToolResultCap: 100,
152+
ToolConfig: map[string]ToolConfig{
153+
"long": {ResultCap: 5},
154+
},
155+
})
156+
if err != nil {
157+
t.Fatal(err)
158+
}
159+
160+
result, truncated := a.runTool(context.Background(), llm.Block{
161+
Type: llm.BlockToolUse,
162+
ToolUseID: "tool-1",
163+
ToolName: "long",
164+
})
165+
if !truncated {
166+
t.Fatal("runTool did not report truncation")
167+
}
168+
if !strings.HasPrefix(result.Text, "hello\n\n") {
169+
t.Fatalf("tool result = %q, want hello prefix", result.Text)
170+
}
171+
}
172+
173+
type blockingTool struct{}
174+
175+
func (blockingTool) Name() string { return "slow" }
176+
177+
func (blockingTool) Description() string { return "slow" }
178+
179+
func (blockingTool) Schema() json.RawMessage { return json.RawMessage(`{"type":"object"}`) }
180+
181+
func (blockingTool) Run(ctx context.Context, input json.RawMessage) (string, error) {
182+
<-ctx.Done()
183+
return "", ctx.Err()
184+
}
185+
186+
func TestRunToolUsesPerToolTimeout(t *testing.T) {
187+
reg := tool.NewRegistry()
188+
reg.Register(blockingTool{})
189+
a, err := New(Options{
190+
Name: "test",
191+
Model: "test-model",
192+
Provider: staticProvider{},
193+
Tools: reg,
194+
ToolConfig: map[string]ToolConfig{
195+
"slow": {Timeout: time.Millisecond},
196+
},
197+
})
198+
if err != nil {
199+
t.Fatal(err)
200+
}
201+
202+
result, truncated := a.runTool(context.Background(), llm.Block{
203+
Type: llm.BlockToolUse,
204+
ToolUseID: "tool-1",
205+
ToolName: "slow",
206+
})
207+
if truncated {
208+
t.Fatal("timeout result should not be marked truncated")
209+
}
210+
if !result.ToolError {
211+
t.Fatal("timeout result should be marked as tool error")
212+
}
213+
if !strings.Contains(result.Text, context.DeadlineExceeded.Error()) {
214+
t.Fatalf("timeout result = %q, want deadline exceeded", result.Text)
215+
}
216+
}
217+
128218
type recordingLogger struct {
129219
events []TurnEvent
130220
}

core/agent/loop.go

Lines changed: 27 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -193,19 +193,43 @@ func (a *Agent) runTool(ctx context.Context, call llm.Block) (out llm.Block, tru
193193
if !ok {
194194
return errResult(call.ToolUseID, fmt.Sprintf("tool not found: %s", call.ToolName)), false
195195
}
196+
cfg := a.configForTool(call.ToolName)
197+
if cfg.Timeout > 0 {
198+
var cancel context.CancelFunc
199+
ctx, cancel = context.WithTimeout(ctx, cfg.Timeout)
200+
defer cancel()
201+
}
196202
output, err := t.Run(ctx, call.ToolInput)
197203
if err != nil {
198204
return errResult(call.ToolUseID, err.Error()), false
199205
}
200-
text, truncated := a.capToolResult(output)
206+
text, truncated := capToolResult(output, a.resultCapForTool(cfg))
201207
return llm.Block{Type: llm.BlockToolResult, ToolUseID: call.ToolUseID, Text: text}, truncated
202208
}
203209

204210
func (a *Agent) capToolResult(output string) (string, bool) {
205-
if a.toolResultCap <= 0 || len(output) <= a.toolResultCap {
211+
return capToolResult(output, a.toolResultCap)
212+
}
213+
214+
func (a *Agent) configForTool(name string) ToolConfig {
215+
if a.toolConfig == nil {
216+
return ToolConfig{}
217+
}
218+
return a.toolConfig[name]
219+
}
220+
221+
func (a *Agent) resultCapForTool(cfg ToolConfig) int {
222+
if cfg.ResultCap > 0 {
223+
return cfg.ResultCap
224+
}
225+
return a.toolResultCap
226+
}
227+
228+
func capToolResult(output string, cap int) (string, bool) {
229+
if cap <= 0 || len(output) <= cap {
206230
return output, false
207231
}
208-
return output[:a.toolResultCap] + fmt.Sprintf("\n\n[tool result truncated: %d bytes omitted]", len(output)-a.toolResultCap), true
232+
return output[:cap] + fmt.Sprintf("\n\n[tool result truncated: %d bytes omitted]", len(output)-cap), true
209233
}
210234

211235
func splitBlocks(blocks []llm.Block) (string, []llm.Block) {

core/character/character.go

Lines changed: 28 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -5,18 +5,20 @@ import (
55
"fmt"
66
"os"
77
"strings"
8+
"time"
89

910
"gopkg.in/yaml.v3"
1011
)
1112

1213
type Character struct {
13-
Name string `yaml:"name"`
14-
Model string `yaml:"model"`
15-
SystemPrompt string `yaml:"system_prompt"`
16-
Provider ProviderConfig `yaml:"provider"`
17-
Memory MemoryConfig `yaml:"memory"`
18-
Context ContextConfig `yaml:"context"`
19-
Tools []string `yaml:"tools"`
14+
Name string `yaml:"name"`
15+
Model string `yaml:"model"`
16+
SystemPrompt string `yaml:"system_prompt"`
17+
Provider ProviderConfig `yaml:"provider"`
18+
Memory MemoryConfig `yaml:"memory"`
19+
Context ContextConfig `yaml:"context"`
20+
Tools []string `yaml:"tools"`
21+
ToolConfig map[string]ToolConfig `yaml:"tool_config"`
2022
}
2123

2224
type ProviderConfig struct {
@@ -34,6 +36,11 @@ type ContextConfig struct {
3436
HistoryTokenBudget int `yaml:"history_token_budget"`
3537
}
3638

39+
type ToolConfig struct {
40+
Timeout string `yaml:"timeout"`
41+
ResultCap int `yaml:"result_cap"`
42+
}
43+
3744
func Load(path string) (*Character, error) {
3845
data, err := os.ReadFile(path)
3946
if err != nil {
@@ -72,6 +79,20 @@ func (c Character) Validate() []string {
7279
if c.Context.HistoryTokenBudget < 0 {
7380
errs = append(errs, "context.history_token_budget cannot be negative")
7481
}
82+
for name, cfg := range c.ToolConfig {
83+
toolName := strings.TrimSpace(name)
84+
if toolName == "" {
85+
errs = append(errs, "tool_config cannot contain empty tool names")
86+
}
87+
if strings.TrimSpace(cfg.Timeout) != "" {
88+
if _, err := time.ParseDuration(cfg.Timeout); err != nil {
89+
errs = append(errs, fmt.Sprintf("tool_config.%s.timeout is invalid: %v", toolName, err))
90+
}
91+
}
92+
if cfg.ResultCap < 0 {
93+
errs = append(errs, fmt.Sprintf("tool_config.%s.result_cap cannot be negative", toolName))
94+
}
95+
}
7596
for _, t := range c.Tools {
7697
if strings.TrimSpace(t) == "" {
7798
errs = append(errs, "tools cannot contain empty names")

core/character/character_test.go

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -138,6 +138,56 @@ context:
138138
}
139139
}
140140

141+
func TestLoadToolConfig(t *testing.T) {
142+
path := filepath.Join(t.TempDir(), "character.yaml")
143+
if err := os.WriteFile(path, []byte(`
144+
name: Helper
145+
model: gpt-4o-mini
146+
system_prompt: Be useful.
147+
tools:
148+
- file_read
149+
tool_config:
150+
file_read:
151+
timeout: 5s
152+
result_cap: 8000
153+
`), 0o600); err != nil {
154+
t.Fatal(err)
155+
}
156+
157+
c, err := Load(path)
158+
if err != nil {
159+
t.Fatal(err)
160+
}
161+
cfg := c.ToolConfig["file_read"]
162+
if cfg.Timeout != "5s" {
163+
t.Fatalf("timeout = %q, want 5s", cfg.Timeout)
164+
}
165+
if cfg.ResultCap != 8000 {
166+
t.Fatalf("result_cap = %d, want 8000", cfg.ResultCap)
167+
}
168+
}
169+
170+
func TestToolConfigValidation(t *testing.T) {
171+
c := Character{
172+
Name: "Tools",
173+
Model: "gpt-4o-mini",
174+
SystemPrompt: "Be useful.",
175+
ToolConfig: map[string]ToolConfig{
176+
"file_read": {Timeout: "soon", ResultCap: -1},
177+
},
178+
}
179+
errs := c.Validate()
180+
if len(errs) != 2 {
181+
t.Fatalf("Validate() = %#v, want two errors", errs)
182+
}
183+
got := strings.Join(errs, "\n")
184+
for _, want := range []string{"tool_config.file_read.timeout", "tool_config.file_read.result_cap"} {
185+
if !strings.Contains(got, want) {
186+
t.Fatalf("Validate() = %#v, want %q", errs, want)
187+
}
188+
}
189+
}
190+
141191
func TestContextValidation(t *testing.T) {
142192
c := Character{
143193
Name: "Context",

docs/CHARACTER.md

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -50,6 +50,11 @@ context:
5050
tools:
5151
- file_list
5252
- file_read
53+
54+
tool_config:
55+
file_read:
56+
timeout: 5s
57+
result_cap: 8000
5358
```
5459
5560
## Fields
@@ -68,6 +73,8 @@ tools:
6873

6974
`tools` is optional. The implemented built-in tools today are `file_list` and `file_read`.
7075

76+
`tool_config` is optional. It configures enabled tools by name. `timeout` uses Go duration strings such as `5s`, `500ms`, or `1m`. `result_cap` limits how many bytes from that tool result are returned to the model before truncation.
77+
7178
## Validation
7279

7380
`vikusha chat character.yaml` validates on startup:

docs/ROADMAP.md

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -73,7 +73,7 @@ A single agent you can talk to from the terminal, backed by a core harness that
7373
- [x] Tool interface with a stable JSON schema so definitions cache cleanly.
7474
- [x] Built-in: `file_read`, `file_list`.
7575
- [ ] Built-in: `bash`, `file_edit`, `web_search`, `web_fetch`.
76-
- [ ] Per-tool timeout and result-cap overrides via character YAML.
76+
- [x] Per-tool timeout and result-cap overrides via character YAML.
7777
- [ ] Danger detection on bash and file writes, with an approval flow.
7878

7979
### Character

0 commit comments

Comments
 (0)