Skip to content

Commit e776f3f

Browse files
santoshkumarradhaAbirAbbasclaude
authored
Add AgentField realtime sessions for WebRTC voice ingress (#654)
* Add explicit realtime session DX * fix: align session route wildcards * test: cover session control-plane paths * docs(examples): add voice dictation example for realtime sessions Runnable demo of the @app.session WebRTC voice flow: browser mic -> control-plane SDP proxy -> OpenAI Realtime, with live transcription and the tools=[...] allowlist routing through execute/async. Includes WSL setup notes and the GA session.update shape (session.type + audio.input nesting) required for input transcription + server VAD. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> * Add session access tags and UI surfaces (#655) * Add session access tags and UI surfaces * test: update session access UI expectations * test: align session access CLI coverage * test: cover session playground tools * test: cover session access UI paths * fix(examples): make voice dictation example resilient to the session route rename #655 renamed the realtime-offer route (/sessions -> /session-instances). The page now tries the /session-instances route first and falls back to /sessions, so it works against #654 alone and against #654+#655. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> --------- Co-authored-by: Abir Abbas <abirabbas1998@gmail.com> Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
1 parent 0401da7 commit e776f3f

63 files changed

Lines changed: 4009 additions & 122 deletions

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

control-plane/internal/cli/root.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -120,6 +120,7 @@ AI Agent? Run "af agent help" for structured JSON output optimized for programma
120120
RootCmd.AddCommand(NewVerifyAliasCommand())
121121
RootCmd.AddCommand(NewNodesCommand())
122122
RootCmd.AddCommand(NewExecutionCommand())
123+
RootCmd.AddCommand(NewSessionCommand())
123124
RootCmd.AddCommand(NewCallCommand())
124125
RootCmd.AddCommand(NewReasonerListCommand())
125126
RootCmd.AddCommand(NewTailCommand())
Lines changed: 302 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,302 @@
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

Comments
 (0)