Skip to content

Commit 3d250ed

Browse files
JAORMXclaudegithub-actions[bot]
authored
Improve composite tool configuration ergonomics (#2589)
* Improve composite tool configuration ergonomics Makes composite tool workflow configuration more user-friendly by reducing verbosity and following "convention over configuration": 1. **Step type inference**: When 'tool' field is present, 'type' is automatically inferred as "tool". Elicitation steps still require explicit type for clarity. 2. **Optional timeout**: Timeout can be omitted and defaults to 30 minutes at runtime. Validator now allows 0 (use default) instead of requiring explicit positive value. Before: ```yaml composite_tools: - name: "fetch_data" timeout: "30m" # Required even for default steps: - id: "fetch" type: "tool" # Redundant when 'tool' field present tool: "fetch_fetch" ``` After: ```yaml composite_tools: - name: "fetch_data" # timeout omitted - uses 30m default steps: - id: "fetch" tool: "fetch_fetch" # Type inferred as "tool" ``` Changes: - Infer type="tool" when tool field is present - Allow timeout=0 in validator (engine applies default) - Handle empty timeout string in YAML loader - Add comprehensive tests for both features 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude <[email protected]> * Move type inference and defaults to YAMLLoader This refactoring addresses the architectural concern that validators should be read-only and not mutate configuration values. The changes follow the 'convention over configuration' principle while maintaining clean separation of concerns. Changes: - Move step type inference from validator to yaml_loader transformWorkflowStep() - Update validator to be read-only (no longer mutates step.Type) - Add validation to prevent ambiguous configurations (both tool and message fields) - Add test case for the ambiguous scenario - Update existing tests to reflect loader-based type inference Benefits: - Clear separation: Loader applies defaults, Validator validates invariants - Follows existing patterns (elicitation timeout defaults in loader) - Platform consistency (CLI and K8s produce same Config before validation) - Validator becomes pure validation logic (no side effects) Co-authored-by: Juan Antonio Osorio <[email protected]> --------- Co-authored-by: Claude <[email protected]> Co-authored-by: claude[bot] <41898282+claude[bot]@users.noreply.github.com> Co-authored-by: Juan Antonio Osorio <[email protected]>
1 parent fbf7354 commit 3d250ed

File tree

3 files changed

+127
-6
lines changed

3 files changed

+127
-6
lines changed

pkg/vmcp/config/validator.go

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -396,8 +396,9 @@ func (v *DefaultValidator) validateCompositeTools(tools []*CompositeToolConfig)
396396
return fmt.Errorf("composite_tools[%d].description is required", i)
397397
}
398398

399-
if tool.Timeout <= 0 {
400-
return fmt.Errorf("composite_tools[%d].timeout must be positive", i)
399+
// Timeout can be 0 (uses default) or positive (explicit timeout)
400+
if tool.Timeout < 0 {
401+
return fmt.Errorf("composite_tools[%d].timeout cannot be negative", i)
401402
}
402403

403404
// Validate steps
@@ -451,8 +452,20 @@ func (*DefaultValidator) validateStepBasics(step *WorkflowStepConfig, index int,
451452
return nil
452453
}
453454

454-
// validateStepType validates step type and type-specific requirements
455+
// validateStepType validates step type and type-specific requirements.
456+
// The type should have been inferred during loading if the 'tool' field is present.
457+
// Elicitation steps must always specify type explicitly for clarity.
455458
func (*DefaultValidator) validateStepType(step *WorkflowStepConfig, index int) error {
459+
// Check for ambiguous configuration: both tool and message fields present
460+
if step.Tool != "" && step.Message != "" {
461+
return fmt.Errorf("step[%d] cannot have both tool and message fields - use explicit type to clarify intent", index)
462+
}
463+
464+
// Type is required at this point (should have been inferred during loading)
465+
if step.Type == "" {
466+
return fmt.Errorf("step[%d].type is required (or omit for tool steps with 'tool' field present)", index)
467+
}
468+
456469
validTypes := []string{"tool", "elicitation"}
457470
if !contains(validTypes, step.Type) {
458471
return fmt.Errorf("step[%d].type must be one of: %s", index, strings.Join(validTypes, ", "))

pkg/vmcp/config/validator_test.go

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -553,6 +553,104 @@ func TestValidator_ValidateCompositeTools(t *testing.T) {
553553
wantErr: true,
554554
errMsg: "duplicate composite tool name",
555555
},
556+
{
557+
name: "type inferred from tool field",
558+
tools: []*CompositeToolConfig{
559+
{
560+
Name: "fetch_data",
561+
Description: "Fetch data workflow",
562+
Timeout: Duration(5 * time.Minute),
563+
Steps: []*WorkflowStepConfig{
564+
{
565+
ID: "fetch",
566+
Type: "tool", // Type would be inferred by loader from tool field
567+
Tool: "fetch_fetch",
568+
Arguments: map[string]any{
569+
"url": "https://example.com",
570+
},
571+
},
572+
},
573+
},
574+
},
575+
wantErr: false,
576+
},
577+
{
578+
name: "timeout omitted uses default",
579+
tools: []*CompositeToolConfig{
580+
{
581+
Name: "no_timeout",
582+
Description: "Workflow without explicit timeout",
583+
Timeout: 0, // Omitted - should use default (30 minutes)
584+
Steps: []*WorkflowStepConfig{
585+
{
586+
ID: "step1",
587+
Type: "tool", // Type would be inferred by loader from tool field
588+
Tool: "some_tool",
589+
},
590+
},
591+
},
592+
},
593+
wantErr: false,
594+
},
595+
{
596+
name: "elicitation requires explicit type",
597+
tools: []*CompositeToolConfig{
598+
{
599+
Name: "confirm_action",
600+
Description: "Confirmation workflow",
601+
Timeout: Duration(5 * time.Minute),
602+
Steps: []*WorkflowStepConfig{
603+
{
604+
ID: "confirm",
605+
Message: "Proceed?", // Elicitation field present
606+
Schema: map[string]any{"type": "object"},
607+
// Type is missing - should fail
608+
},
609+
},
610+
},
611+
},
612+
wantErr: true,
613+
errMsg: "type is required",
614+
},
615+
{
616+
name: "missing both type and identifying fields",
617+
tools: []*CompositeToolConfig{
618+
{
619+
Name: "invalid_step",
620+
Description: "Invalid step workflow",
621+
Timeout: Duration(5 * time.Minute),
622+
Steps: []*WorkflowStepConfig{
623+
{
624+
ID: "step1",
625+
// No type, no tool, no message - cannot infer
626+
},
627+
},
628+
},
629+
},
630+
wantErr: true,
631+
errMsg: "type is required",
632+
},
633+
{
634+
name: "both tool and message fields present",
635+
tools: []*CompositeToolConfig{
636+
{
637+
Name: "ambiguous_step",
638+
Description: "Step with both tool and message",
639+
Timeout: Duration(5 * time.Minute),
640+
Steps: []*WorkflowStepConfig{
641+
{
642+
ID: "step1",
643+
Tool: "some_tool", // Tool field present
644+
Message: "Some message", // Message field also present
645+
// Type will be inferred as "tool" during loading
646+
// This should fail validation due to ambiguity
647+
},
648+
},
649+
},
650+
},
651+
wantErr: true,
652+
errMsg: "cannot have both tool and message fields",
653+
},
556654
}
557655

558656
for _, tt := range tests {

pkg/vmcp/config/yaml_loader.go

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -495,9 +495,14 @@ func (l *YAMLLoader) transformCompositeTools(raw []*rawCompositeTool) ([]*Compos
495495
var tools []*CompositeToolConfig
496496

497497
for _, rawTool := range raw {
498-
timeout, err := time.ParseDuration(rawTool.Timeout)
499-
if err != nil {
500-
return nil, fmt.Errorf("tool %s: invalid timeout: %w", rawTool.Name, err)
498+
// Parse timeout - empty string means use default (0 duration)
499+
var timeout time.Duration
500+
if rawTool.Timeout != "" {
501+
var err error
502+
timeout, err = time.ParseDuration(rawTool.Timeout)
503+
if err != nil {
504+
return nil, fmt.Errorf("tool %s: invalid timeout: %w", rawTool.Name, err)
505+
}
501506
}
502507

503508
tool := &CompositeToolConfig{
@@ -553,6 +558,11 @@ func (*YAMLLoader) transformWorkflowStep(raw *rawWorkflowStep) (*WorkflowStepCon
553558
Schema: raw.Schema,
554559
}
555560

561+
// Apply type inference: if type is empty and tool field is present, infer as "tool"
562+
if step.Type == "" && step.Tool != "" {
563+
step.Type = "tool"
564+
}
565+
556566
if raw.Timeout != "" {
557567
timeout, err := time.ParseDuration(raw.Timeout)
558568
if err != nil {

0 commit comments

Comments
 (0)