|
| 1 | +package cli |
| 2 | + |
| 3 | +import ( |
| 4 | + "context" |
| 5 | + "encoding/json" |
| 6 | + "fmt" |
| 7 | + "io" |
| 8 | + "net/http" |
| 9 | + "net/url" |
| 10 | + "os" |
| 11 | + "strings" |
| 12 | + |
| 13 | + "github.com/spf13/cobra" |
| 14 | +) |
| 15 | + |
| 16 | +type sessionStartOptions struct { |
| 17 | + provider string |
| 18 | + transport string |
| 19 | + model string |
| 20 | + voice string |
| 21 | + outputFormat string |
| 22 | + stdout io.Writer |
| 23 | +} |
| 24 | + |
| 25 | +type sessionToolOptions struct { |
| 26 | + target string |
| 27 | + inputSource string |
| 28 | + outputFormat string |
| 29 | + stdin io.Reader |
| 30 | + stdout io.Writer |
| 31 | +} |
| 32 | + |
| 33 | +type sessionOfferOptions struct { |
| 34 | + provider string |
| 35 | + transport string |
| 36 | + sdpSource string |
| 37 | + outputFormat string |
| 38 | + stdin io.Reader |
| 39 | + stdout io.Writer |
| 40 | +} |
| 41 | + |
| 42 | +func NewSessionCommand() *cobra.Command { |
| 43 | + cmd := &cobra.Command{ |
| 44 | + Use: "session", |
| 45 | + Short: "Start and interact with AgentField realtime sessions", |
| 46 | + } |
| 47 | + cmd.AddCommand(newSessionStartCommand()) |
| 48 | + cmd.AddCommand(newSessionOfferCommand()) |
| 49 | + cmd.AddCommand(newSessionToolCommand()) |
| 50 | + cmd.AddCommand(newSessionWorkflowsCommand()) |
| 51 | + return cmd |
| 52 | +} |
| 53 | + |
| 54 | +func newSessionStartCommand() *cobra.Command { |
| 55 | + opts := &sessionStartOptions{} |
| 56 | + cmd := &cobra.Command{ |
| 57 | + Use: "start <node>.<session>", |
| 58 | + Short: "Start a provider-backed AgentField session", |
| 59 | + Args: cobra.ExactArgs(1), |
| 60 | + RunE: func(cmd *cobra.Command, args []string) error { |
| 61 | + ctx, cancel := commandContext() |
| 62 | + defer cancel() |
| 63 | + opts.stdout = os.Stdout |
| 64 | + return runSessionStart(ctx, args[0], opts) |
| 65 | + }, |
| 66 | + } |
| 67 | + cmd.Flags().StringVar(&opts.provider, "provider", "", "Explicit session provider, e.g. openai") |
| 68 | + cmd.Flags().StringVar(&opts.transport, "transport", "", "Explicit session transport, e.g. webrtc") |
| 69 | + cmd.Flags().StringVar(&opts.model, "model", "", "Provider model") |
| 70 | + cmd.Flags().StringVar(&opts.voice, "voice", "", "Provider voice") |
| 71 | + cmd.Flags().StringVarP(&opts.outputFormat, "output", "o", "json", "Output format: json, pretty, yaml") |
| 72 | + return cmd |
| 73 | +} |
| 74 | + |
| 75 | +func runSessionStart(ctx context.Context, target string, opts *sessionStartOptions) error { |
| 76 | + if opts.stdout == nil { |
| 77 | + opts.stdout = os.Stdout |
| 78 | + } |
| 79 | + payload := map[string]interface{}{ |
| 80 | + "provider": opts.provider, |
| 81 | + "transport": opts.transport, |
| 82 | + "model": opts.model, |
| 83 | + "voice": opts.voice, |
| 84 | + } |
| 85 | + resp, err := makeRequest(ctx, http.MethodPost, "/api/v1/session-targets/"+target+"/start", payload, "application/json") |
| 86 | + if err != nil { |
| 87 | + return cliExitError{Code: 3, Err: err} |
| 88 | + } |
| 89 | + var decoded map[string]interface{} |
| 90 | + body, err := readJSONResponse(resp, &decoded) |
| 91 | + if err != nil { |
| 92 | + return cliExitError{Code: 3, Err: err} |
| 93 | + } |
| 94 | + if resp.StatusCode >= http.StatusBadRequest { |
| 95 | + return cliExitError{Code: httpExitCode(resp.StatusCode), Err: fmt.Errorf("session start failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))} |
| 96 | + } |
| 97 | + return writeValue(opts.stdout, decoded, autoOutputFormat(opts.outputFormat, false)) |
| 98 | +} |
| 99 | + |
| 100 | +func newSessionOfferCommand() *cobra.Command { |
| 101 | + opts := &sessionOfferOptions{} |
| 102 | + cmd := &cobra.Command{ |
| 103 | + Use: "offer <session_id>", |
| 104 | + Short: "Create a realtime WebRTC offer through the control plane", |
| 105 | + Args: cobra.ExactArgs(1), |
| 106 | + RunE: func(cmd *cobra.Command, args []string) error { |
| 107 | + ctx, cancel := commandContext() |
| 108 | + defer cancel() |
| 109 | + opts.stdin = os.Stdin |
| 110 | + opts.stdout = os.Stdout |
| 111 | + return runSessionOffer(ctx, args[0], opts) |
| 112 | + }, |
| 113 | + } |
| 114 | + cmd.Flags().StringVar(&opts.provider, "provider", "", "Explicit session provider") |
| 115 | + cmd.Flags().StringVar(&opts.transport, "transport", "", "Explicit session transport") |
| 116 | + cmd.Flags().StringVar(&opts.sdpSource, "sdp", "", "SDP offer as inline text, @path, or - for stdin; defaults to stdin") |
| 117 | + cmd.Flags().StringVarP(&opts.outputFormat, "output", "o", "raw", "Output format: raw, json, pretty, yaml") |
| 118 | + return cmd |
| 119 | +} |
| 120 | + |
| 121 | +func runSessionOffer(ctx context.Context, sessionID string, opts *sessionOfferOptions) error { |
| 122 | + if opts.stdout == nil { |
| 123 | + opts.stdout = os.Stdout |
| 124 | + } |
| 125 | + sdp, err := readSessionSDP(opts.sdpSource, opts.stdin) |
| 126 | + if err != nil { |
| 127 | + return cliExitError{Code: 2, Err: err} |
| 128 | + } |
| 129 | + values := url.Values{} |
| 130 | + if strings.TrimSpace(opts.provider) != "" { |
| 131 | + values.Set("provider", opts.provider) |
| 132 | + } |
| 133 | + if strings.TrimSpace(opts.transport) != "" { |
| 134 | + values.Set("transport", opts.transport) |
| 135 | + } |
| 136 | + path := "/api/v1/session-instances/" + url.PathEscape(sessionID) + "/realtime-offer" |
| 137 | + if encoded := values.Encode(); encoded != "" { |
| 138 | + path += "?" + encoded |
| 139 | + } |
| 140 | + resp, err := makeRawRequest(ctx, http.MethodPost, path, strings.NewReader(sdp), "application/sdp", "application/sdp") |
| 141 | + if err != nil { |
| 142 | + return cliExitError{Code: 3, Err: err} |
| 143 | + } |
| 144 | + defer resp.Body.Close() |
| 145 | + body, err := io.ReadAll(resp.Body) |
| 146 | + if err != nil { |
| 147 | + return cliExitError{Code: 3, Err: fmt.Errorf("read response: %w", err)} |
| 148 | + } |
| 149 | + if resp.StatusCode >= http.StatusBadRequest { |
| 150 | + return cliExitError{Code: httpExitCode(resp.StatusCode), Err: fmt.Errorf("session offer failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))} |
| 151 | + } |
| 152 | + |
| 153 | + format := strings.ToLower(strings.TrimSpace(opts.outputFormat)) |
| 154 | + if format == "" || format == "raw" { |
| 155 | + _, err := opts.stdout.Write(body) |
| 156 | + return err |
| 157 | + } |
| 158 | + return writeValue(opts.stdout, map[string]interface{}{"answer_sdp": string(body)}, autoOutputFormat(format, false)) |
| 159 | +} |
| 160 | + |
| 161 | +func readSessionSDP(source string, stdin io.Reader) (string, error) { |
| 162 | + sourceToken := strings.TrimSpace(source) |
| 163 | + switch { |
| 164 | + case sourceToken == "" || sourceToken == "-": |
| 165 | + if stdin == nil { |
| 166 | + return "", fmt.Errorf("SDP offer required; pass --sdp, --sdp @path, or pipe SDP on stdin") |
| 167 | + } |
| 168 | + data, err := io.ReadAll(stdin) |
| 169 | + if err != nil { |
| 170 | + return "", fmt.Errorf("read SDP from stdin: %w", err) |
| 171 | + } |
| 172 | + if strings.TrimSpace(string(data)) == "" { |
| 173 | + return "", fmt.Errorf("SDP offer required; pass --sdp, --sdp @path, or pipe SDP on stdin") |
| 174 | + } |
| 175 | + return string(data), nil |
| 176 | + case strings.HasPrefix(sourceToken, "@"): |
| 177 | + path := strings.TrimSpace(strings.TrimPrefix(sourceToken, "@")) |
| 178 | + if path == "" { |
| 179 | + return "", fmt.Errorf("SDP file path is required after @") |
| 180 | + } |
| 181 | + data, err := os.ReadFile(path) |
| 182 | + if err != nil { |
| 183 | + return "", fmt.Errorf("read SDP file %s: %w", path, err) |
| 184 | + } |
| 185 | + if strings.TrimSpace(string(data)) == "" { |
| 186 | + return "", fmt.Errorf("SDP file %s is empty", path) |
| 187 | + } |
| 188 | + return string(data), nil |
| 189 | + default: |
| 190 | + if strings.TrimSpace(source) == "" { |
| 191 | + return "", fmt.Errorf("SDP offer required; pass --sdp, --sdp @path, or pipe SDP on stdin") |
| 192 | + } |
| 193 | + return source, nil |
| 194 | + } |
| 195 | +} |
| 196 | + |
| 197 | +func makeRawRequest(ctx context.Context, method, path string, body io.Reader, contentType string, accept string) (*http.Response, error) { |
| 198 | + server := strings.TrimRight(GetServerURL(), "/") |
| 199 | + if !strings.HasPrefix(path, "/") { |
| 200 | + path = "/" + path |
| 201 | + } |
| 202 | + req, err := http.NewRequestWithContext(ctx, method, server+path, body) |
| 203 | + if err != nil { |
| 204 | + return nil, fmt.Errorf("build request: %w", err) |
| 205 | + } |
| 206 | + if accept == "" { |
| 207 | + accept = "application/json" |
| 208 | + } |
| 209 | + req.Header.Set("Accept", accept) |
| 210 | + req.Header.Set("User-Agent", "af-cli/session") |
| 211 | + if strings.TrimSpace(contentType) != "" { |
| 212 | + req.Header.Set("Content-Type", contentType) |
| 213 | + } |
| 214 | + if key := strings.TrimSpace(GetAPIKey()); key != "" { |
| 215 | + req.Header.Set("X-API-Key", key) |
| 216 | + } |
| 217 | + client := triggerHTTPClient(accept) |
| 218 | + return client.Do(req) |
| 219 | +} |
| 220 | + |
| 221 | +func newSessionToolCommand() *cobra.Command { |
| 222 | + opts := &sessionToolOptions{} |
| 223 | + cmd := &cobra.Command{ |
| 224 | + Use: "tool <session_id> <tool>", |
| 225 | + Short: "Invoke a session tool through AgentField execute/async", |
| 226 | + Args: cobra.ExactArgs(2), |
| 227 | + RunE: func(cmd *cobra.Command, args []string) error { |
| 228 | + ctx, cancel := commandContext() |
| 229 | + defer cancel() |
| 230 | + opts.stdin = os.Stdin |
| 231 | + opts.stdout = os.Stdout |
| 232 | + return runSessionTool(ctx, args[0], args[1], opts) |
| 233 | + }, |
| 234 | + } |
| 235 | + cmd.Flags().StringVar(&opts.target, "target", "", "Explicit <node>.<reasoner> target") |
| 236 | + cmd.Flags().StringVar(&opts.inputSource, "in", "", "Input payload as inline JSON or @path") |
| 237 | + cmd.Flags().StringVarP(&opts.outputFormat, "output", "o", "json", "Output format: json, pretty, yaml") |
| 238 | + return cmd |
| 239 | +} |
| 240 | + |
| 241 | +func runSessionTool(ctx context.Context, sessionID string, tool string, opts *sessionToolOptions) error { |
| 242 | + input := map[string]interface{}{} |
| 243 | + if strings.TrimSpace(opts.inputSource) != "" { |
| 244 | + parsed, err := parseInputSource(opts.inputSource) |
| 245 | + if err != nil { |
| 246 | + return cliExitError{Code: 2, Err: err} |
| 247 | + } |
| 248 | + input = parsed |
| 249 | + } else if opts.stdin != nil { |
| 250 | + data, _ := io.ReadAll(opts.stdin) |
| 251 | + if len(strings.TrimSpace(string(data))) > 0 { |
| 252 | + if err := json.Unmarshal(data, &input); err != nil { |
| 253 | + return cliExitError{Code: 2, Err: fmt.Errorf("parse stdin JSON: %w", err)} |
| 254 | + } |
| 255 | + } |
| 256 | + } |
| 257 | + payload := map[string]interface{}{"target": opts.target, "input": input} |
| 258 | + resp, err := makeRequest(ctx, http.MethodPost, "/api/v1/session-instances/"+sessionID+"/tools/"+tool, payload, "application/json") |
| 259 | + if err != nil { |
| 260 | + return cliExitError{Code: 3, Err: err} |
| 261 | + } |
| 262 | + var decoded map[string]interface{} |
| 263 | + body, err := readJSONResponse(resp, &decoded) |
| 264 | + if err != nil { |
| 265 | + return cliExitError{Code: 3, Err: err} |
| 266 | + } |
| 267 | + if resp.StatusCode >= http.StatusBadRequest { |
| 268 | + return cliExitError{Code: httpExitCode(resp.StatusCode), Err: fmt.Errorf("session tool failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))} |
| 269 | + } |
| 270 | + return writeValue(opts.stdout, decoded, autoOutputFormat(opts.outputFormat, false)) |
| 271 | +} |
| 272 | + |
| 273 | +func newSessionWorkflowsCommand() *cobra.Command { |
| 274 | + var output string |
| 275 | + cmd := &cobra.Command{ |
| 276 | + Use: "workflows <session_id>", |
| 277 | + Short: "List workflows associated with a session", |
| 278 | + Args: cobra.ExactArgs(1), |
| 279 | + RunE: func(cmd *cobra.Command, args []string) error { |
| 280 | + ctx, cancel := commandContext() |
| 281 | + defer cancel() |
| 282 | + resp, err := makeRequest(ctx, http.MethodPost, "/api/v1/agentic/query", map[string]interface{}{ |
| 283 | + "resource": "workflows", |
| 284 | + "filters": map[string]interface{}{"session_id": args[0]}, |
| 285 | + }, "application/json") |
| 286 | + if err != nil { |
| 287 | + return cliExitError{Code: 3, Err: err} |
| 288 | + } |
| 289 | + var decoded map[string]interface{} |
| 290 | + body, err := readJSONResponse(resp, &decoded) |
| 291 | + if err != nil { |
| 292 | + return cliExitError{Code: 3, Err: err} |
| 293 | + } |
| 294 | + if resp.StatusCode >= http.StatusBadRequest { |
| 295 | + return cliExitError{Code: httpExitCode(resp.StatusCode), Err: fmt.Errorf("session workflows failed with status %d: %s", resp.StatusCode, strings.TrimSpace(string(body)))} |
| 296 | + } |
| 297 | + return writeValue(os.Stdout, decoded, autoOutputFormat(output, false)) |
| 298 | + }, |
| 299 | + } |
| 300 | + cmd.Flags().StringVarP(&output, "output", "o", "json", "Output format: json, pretty, yaml") |
| 301 | + return cmd |
| 302 | +} |
0 commit comments