Changeset 0.35.0 (#209)

Co-authored-by: Jeffrey Smith <jasafpro@gmail.com>
Co-committed-by: Jeffrey Smith <jasafpro@gmail.com>
This commit is contained in:
2026-03-20 09:59:53 +00:00
committed by xcaliber
parent d16bb93177
commit bf8082e69f
37 changed files with 2324 additions and 129 deletions

View File

@@ -10,6 +10,7 @@ import (
"chat-switchboard/models"
"chat-switchboard/store"
"chat-switchboard/webhook"
"chat-switchboard/workflow"
)
// ── Late Registration ────────────────────────
@@ -22,7 +23,8 @@ func RegisterWorkflowTools(stores store.Stores) {
return
}
Register(&workflowAdvanceTool{stores: stores})
log.Println("✅ workflow tools registered (workflow_advance)")
Register(&workflowRouteTool{stores: stores})
log.Println("✅ workflow tools registered (workflow_advance, workflow_route)")
}
// ═══════════════════════════════════════════
@@ -98,7 +100,10 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont
// Merge collected data into stage_data
mergedData := MergeWorkflowStageData(ctx, t.stores.Channels, channelID, args.Data)
nextStage := currentStage + 1
nextStage, err := workflow.ResolveNextStage(stages, currentStage, json.RawMessage(mergedData))
if err != nil {
return "", fmt.Errorf("routing error: %w", err)
}
if nextStage >= len(stages) {
// Workflow complete
@@ -151,6 +156,117 @@ func (t *workflowAdvanceTool) Execute(ctx context.Context, execCtx ExecutionCont
return string(result), nil
}
// ═══════════════════════════════════════════
// workflow_route (v0.35.0)
// ═══════════════════════════════════════════
//
// Called by a stage persona to route the workflow to a specific named stage.
// Enables AI-triggered routing (escalation, correction loops) and
// conditional branching driven by persona judgment.
type workflowRouteTool struct {
BaseTool
stores store.Stores
}
func (t *workflowRouteTool) Availability() Require { return RequireWorkflow }
func (t *workflowRouteTool) Definition() ToolDef {
return ToolDef{
Name: "workflow_route",
DisplayName: "Route Workflow",
Category: "workflow",
Description: "Route the workflow to a specific stage by name. Use this when the " +
"conversation should jump to a particular stage instead of advancing sequentially. " +
"Common patterns: escalation to human review, loop back for correction, " +
"skip stages based on collected information.",
Parameters: JSONSchema(map[string]interface{}{
"target_stage": Prop("string", "Name of the stage to route to (case-insensitive)."),
"reason": Prop("string", "Brief explanation of why this routing decision was made."),
}, []string{"target_stage", "reason"}),
}
}
func (t *workflowRouteTool) Execute(ctx context.Context, execCtx ExecutionContext, argsJSON string) (string, error) {
var args struct {
TargetStage string `json:"target_stage"`
Reason string `json:"reason"`
}
if err := json.Unmarshal([]byte(argsJSON), &args); err != nil {
return "", fmt.Errorf("invalid arguments: %w", err)
}
if args.TargetStage == "" {
return "", fmt.Errorf("target_stage is required")
}
channelID := execCtx.ChannelID
if channelID == "" {
return "", fmt.Errorf("no channel context")
}
ws, err := t.stores.Channels.GetWorkflowStatus(ctx, channelID)
if err != nil || ws == nil || ws.WorkflowID == nil {
return "", fmt.Errorf("not a workflow channel")
}
if ws.Status != "active" {
return "", fmt.Errorf("workflow is %s, cannot route", ws.Status)
}
stages, err := t.stores.Workflows.ListStages(ctx, *ws.WorkflowID)
if err != nil {
return "", fmt.Errorf("failed to load stages: %w", err)
}
targetOrdinal, err := workflow.ResolveStageByName(stages, args.TargetStage)
if err != nil {
return "", fmt.Errorf("cannot resolve target: %w", err)
}
// Record routing decision in stage_data
routeEntry, _ := json.Marshal(map[string]any{
"from": ws.CurrentStage, "to": targetOrdinal,
"reason": args.Reason, "ts": time.Now().UTC().Format(time.RFC3339),
})
historyPatch, _ := json.Marshal(map[string]any{
"_route_history_latest": json.RawMessage(routeEntry),
})
mergedData := MergeWorkflowStageData(ctx, t.stores.Channels, channelID, json.RawMessage(historyPatch))
if targetOrdinal >= len(stages) {
// Route beyond last stage = complete
err = t.stores.Channels.CompleteWorkflow(ctx, channelID, targetOrdinal, json.RawMessage(mergedData))
if err != nil {
return "", fmt.Errorf("failed to complete workflow: %w", err)
}
go TriggerWorkflowOnComplete(ctx, t.stores, *ws.WorkflowID, channelID, mergedData)
result, _ := json.Marshal(map[string]any{
"status": "completed", "message": "Workflow completed via routing.",
})
return string(result), nil
}
err = t.stores.Channels.AdvanceWorkflowStage(ctx, channelID, targetOrdinal, json.RawMessage(mergedData))
if err != nil {
return "", fmt.Errorf("failed to route: %w", err)
}
targetDef := stages[targetOrdinal]
// Create assignment if target stage has an assignment team
if targetDef.AssignmentTeamID != nil {
CreateWorkflowAssignment(ctx, t.stores, channelID, targetOrdinal, *targetDef.AssignmentTeamID)
}
result, _ := json.Marshal(map[string]any{
"status": "active",
"current_stage": targetOrdinal,
"stage_name": targetDef.Name,
"stage_mode": targetDef.StageMode,
"message": fmt.Sprintf("Routed to stage %d: %s (reason: %s)", targetOrdinal, targetDef.Name, args.Reason),
})
return string(result), nil
}
// ── Shared helpers (used by both tool and handler) ─
// MergeWorkflowStageData reads current stage_data, merges new data in Go, returns JSON string.