From 65d1d80e46224017c1f8f1c9b45d43487f9cc686 Mon Sep 17 00:00:00 2001 From: Mark Cavage Date: Sat, 6 Dec 2025 16:10:37 -0800 Subject: [PATCH] Support agent paths with slashes in API routing MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Replace two separate agent routes with a single wildcard route that can handle agent paths containing slashes. This allows agents to be organized in nested directories or namespaces. The new routing logic: - Uses a wildcard parameter to capture the full agent path - Handles paths with or without leading slashes - Supports optional agent_name as the final path segment - Maintains backward compatibility with existing paths 🤖 Generated with [Claude Code](https://claude.com/claude-code) Co-Authored-By: Claude Sonnet 4.5 --- pkg/server/server.go | 67 +++++++++++++- pkg/server/server_test.go | 187 +++++++++++++++++++++++++++++++++++++- 2 files changed, 245 insertions(+), 9 deletions(-) diff --git a/pkg/server/server.go b/pkg/server/server.go index 51fd4be16..1b43bb900 100644 --- a/pkg/server/server.go +++ b/pkg/server/server.go @@ -70,8 +70,8 @@ func New(sessionStore session.Store, runConfig *config.RuntimeConfig, agentSourc // Delete a session group.DELETE("/sessions/:id", s.deleteSession) // Run an agent loop - group.POST("/sessions/:id/agent/:agent", s.runAgent) - group.POST("/sessions/:id/agent/:agent/:agent_name", s.runAgent) + // Use wildcard to capture agent path which may contain slashes + group.POST("/sessions/:id/agent/*", s.runAgent) group.POST("/sessions/:id/elicitation", s.elicitation) // Health check endpoint @@ -289,10 +289,67 @@ func (s *Server) deleteSession(c echo.Context) error { func (s *Server) runAgent(c echo.Context) error { sessionID := c.Param("id") - agentFilename := c.Param("agent") - currentAgent := c.Param("agent_name") - if currentAgent == "" { + + // Parse wildcard parameter which contains agent path and optional agent_name + // Format: agent_path or agent_path/agent_name (may or may not have leading /) + wildcardPath := c.Param("*") + + // Split into agent path and agent name + // The agent name (if present) is always the last segment after the final / + // We need to check if the last segment is a valid agent file path or an agent name + var agentFilename, currentAgent string + + // Helper function to try both with and without leading slash + tryFindAgent := func(path string) (string, bool) { + // Try as-is + if _, found := s.agentSources[path]; found { + return path, true + } + // Try with leading slash + withSlash := "/" + path + if _, found := s.agentSources[withSlash]; found { + return withSlash, true + } + // Try without leading slash + withoutSlash := strings.TrimPrefix(path, "/") + if _, found := s.agentSources[withoutSlash]; found { + return withoutSlash, true + } + return "", false + } + + // Try looking up the full path in agentSources + if found, ok := tryFindAgent(wildcardPath); ok { + agentFilename = found currentAgent = "root" + } else { + // Try splitting off the last segment as agent_name + lastSlash := strings.LastIndex(wildcardPath, "/") + if lastSlash >= 0 { + possibleAgentPath := wildcardPath[:lastSlash] + possibleAgentName := wildcardPath[lastSlash+1:] + + if found, ok := tryFindAgent(possibleAgentPath); ok { + agentFilename = found + currentAgent = possibleAgentName + } else { + // Fallback: use full path as agent, default agent name + if found, ok := tryFindAgent(wildcardPath); ok { + agentFilename = found + } else { + agentFilename = wildcardPath + } + currentAgent = "root" + } + } else { + // No slashes, just use as agent filename + if found, ok := tryFindAgent(wildcardPath); ok { + agentFilename = found + } else { + agentFilename = wildcardPath + } + currentAgent = "root" + } } slog.Debug("Running agent", "agent_filename", agentFilename, "session_id", sessionID, "current_agent", currentAgent) diff --git a/pkg/server/server_test.go b/pkg/server/server_test.go index e123d004b..dcdcb9b88 100644 --- a/pkg/server/server_test.go +++ b/pkg/server/server_test.go @@ -69,6 +69,147 @@ func TestServer_ListSessions(t *testing.T) { assert.Empty(t, sessions) } +func TestServer_WildcardAgentRouting(t *testing.T) { + t.Setenv("OPENAI_API_KEY", "dummy") + t.Setenv("ANTHROPIC_API_KEY", "dummy") + + ctx := t.Context() + + // Create test agent files + agentsDir := filepath.Join(t.TempDir(), "agents") + err := os.MkdirAll(agentsDir, 0o700) + require.NoError(t, err) + + // Copy test files + testFiles := []string{"pirate.yaml", "multi_agents.yaml", "contradict.yaml"} + for _, file := range testFiles { + buf, err := os.ReadFile(filepath.Join("testdata", file)) + require.NoError(t, err) + err = os.WriteFile(filepath.Join(agentsDir, file), buf, 0o600) + require.NoError(t, err) + } + + // Manually create sources with keys that contain slashes to test wildcard routing + store := &mockStore{} + runConfig := config.RuntimeConfig{} + + sources := make(config.Sources) + sources["pirate.yaml"] = config.NewFileSource(filepath.Join(agentsDir, "pirate.yaml")) + sources["teams/multi.yaml"] = config.NewFileSource(filepath.Join(agentsDir, "multi_agents.yaml")) + sources["deep/nested/path/contradict.yaml"] = config.NewFileSource(filepath.Join(agentsDir, "contradict.yaml")) + + srv, err := New(store, &runConfig, sources) + require.NoError(t, err) + + socketPath := "unix://" + filepath.Join(t.TempDir(), "sock") + ln, err := Listen(ctx, socketPath) + require.NoError(t, err) + go func() { + <-ctx.Done() + _ = ln.Close() + }() + go func() { + _ = srv.Serve(ctx, ln) + }() + lnPath := socketPath + + // Verify agents are available + buf := httpGET(t, ctx, lnPath, "/api/agents") + var agents []api.Agent + unmarshal(t, buf, &agents) + require.Len(t, agents, 3, "Expected 3 agents to be available") + + // Test various wildcard routing patterns + tests := []struct { + name string + agentPath string + expectError bool + }{ + { + name: "simple agent path", + agentPath: "pirate.yaml", + expectError: false, + }, + { + name: "agent path with single slash", + agentPath: "teams/multi.yaml", + expectError: false, + }, + { + name: "agent path with multiple slashes", + agentPath: "deep/nested/path/contradict.yaml", + expectError: false, + }, + { + name: "simple agent path with leading slash", + agentPath: "/pirate.yaml", + expectError: false, + }, + { + name: "nested agent path with leading slash", + agentPath: "/teams/multi.yaml", + expectError: false, + }, + { + name: "agent path with agent name", + agentPath: "pirate.yaml/root", + expectError: false, + }, + { + name: "nested agent path with agent name", + agentPath: "teams/multi.yaml/root", + expectError: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Create a test session + payload := session.Session{ + WorkingDir: t.TempDir(), + } + sessionBuf := httpDo(t, ctx, http.MethodPost, lnPath, "/api/sessions", payload) + var sess session.Session + unmarshal(t, sessionBuf, &sess) + + // Attempt to call the agent endpoint + // Note: This will fail because we don't have a full runtime setup, + // but it should at least validate that the route is matched and + // basic parameter parsing works + url := "/api/sessions/" + sess.ID + "/agent/" + strings.TrimPrefix(tt.agentPath, "/") + + // We expect this to fail in a specific way (not a 404 route error) + // A 404 would indicate the route wasn't matched + // Other errors are expected due to missing runtime components + req, err := http.NewRequestWithContext(ctx, http.MethodPost, "http://_"+url, strings.NewReader(`[{"content":"test"}]`)) + require.NoError(t, err) + req.Header.Set("Content-Type", "application/json") + + client := &http.Client{ + Transport: &http.Transport{ + DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) { + var d net.Dialer + return d.DialContext(ctx, "unix", strings.TrimPrefix(lnPath, "unix://")) + }, + }, + } + resp, err := client.Do(req) + require.NoError(t, err) + defer resp.Body.Close() + + body, err := io.ReadAll(resp.Body) + require.NoError(t, err) + + // The route should be matched (not a 404) + // It may fail with 500 due to runtime setup, but that's okay for this test + // We're mainly testing that the wildcard routing works + if resp.StatusCode == http.StatusNotFound { + t.Errorf("Route not matched for path %s, got 404. Body: %s", tt.agentPath, string(body)) + } + }) + } +} + func prepareAgentsDir(t *testing.T, testFiles ...string) string { t.Helper() @@ -90,7 +231,7 @@ func prepareAgentsDir(t *testing.T, testFiles ...string) string { func startServer(t *testing.T, ctx context.Context, agentsDir string) string { t.Helper() - var store mockStore + store := &mockStore{} runConfig := config.RuntimeConfig{} sources, err := config.ResolveSources(agentsDir) @@ -170,9 +311,47 @@ func unmarshal(t *testing.T, buf []byte, v any) { } type mockStore struct { - session.Store + sessions map[string]*session.Session +} + +func (s *mockStore) init() { + if s.sessions == nil { + s.sessions = make(map[string]*session.Session) + } +} + +func (s *mockStore) AddSession(_ context.Context, sess *session.Session) error { + s.init() + s.sessions[sess.ID] = sess + return nil +} + +func (s *mockStore) GetSession(_ context.Context, id string) (*session.Session, error) { + s.init() + sess, ok := s.sessions[id] + if !ok { + return nil, session.ErrNotFound + } + return sess, nil +} + +func (s *mockStore) GetSessions(_ context.Context) ([]*session.Session, error) { + s.init() + var sessions []*session.Session + for _, sess := range s.sessions { + sessions = append(sessions, sess) + } + return sessions, nil +} + +func (s *mockStore) DeleteSession(_ context.Context, id string) error { + s.init() + delete(s.sessions, id) + return nil } -func (s mockStore) GetSessions(context.Context) ([]*session.Session, error) { - return nil, nil +func (s *mockStore) UpdateSession(_ context.Context, sess *session.Session) error { + s.init() + s.sessions[sess.ID] = sess + return nil }