Skip to content

Commit 349d2da

Browse files
committed
feat: enable sharing of chat sessions
Closes #1933 Signed-off-by: Brian Fox <878612+onematchfox@users.noreply.github.com>
1 parent e9d43f6 commit 349d2da

59 files changed

Lines changed: 2974 additions & 147 deletions

Some content is hidden

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

go/adk/cmd/main.go

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -173,7 +173,7 @@ func main() {
173173
logger.Info("Memory service enabled", "appName", appName)
174174
}
175175

176-
runnerConfig, subagentSessionIDs, err := runnerpkg.CreateRunnerConfig(ctx, agentConfig, sessionService, appName, memoryService)
176+
runnerConfig, subagentSessionIDs, err := runnerpkg.CreateRunnerConfig(ctx, agentConfig, sessionService, appName, memoryService, kagentURL, httpClient)
177177
if err != nil {
178178
logger.Error(err, "Failed to create Google ADK Runner config")
179179
os.Exit(1)

go/adk/pkg/runner/adapter.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ package runner
33
import (
44
"context"
55
"fmt"
6+
"net/http"
67
"os"
78
"strings"
89

@@ -11,6 +12,7 @@ import (
1112
kagentmemory "github.com/kagent-dev/kagent/go/adk/pkg/memory"
1213
"github.com/kagent-dev/kagent/go/adk/pkg/session"
1314
"github.com/kagent-dev/kagent/go/adk/pkg/sts"
15+
"github.com/kagent-dev/kagent/go/adk/pkg/tools"
1416
"github.com/kagent-dev/kagent/go/api/adk"
1517
adkmemory "google.golang.org/adk/memory"
1618
adkplugin "google.golang.org/adk/plugin"
@@ -34,6 +36,8 @@ func CreateRunnerConfig(
3436
sessionService *session.KAgentSessionService,
3537
appName string,
3638
memoryService *kagentmemory.KagentMemoryService,
39+
kagentURL string,
40+
httpClient *http.Client,
3741
) (runner.Config, map[string]string, error) {
3842
log := logr.FromContextOrDiscard(ctx)
3943

@@ -46,6 +50,23 @@ func CreateRunnerConfig(
4650
extraTools = append(extraTools, saveTool)
4751
}
4852

53+
if agentConfig.ShareTools != nil && *agentConfig.ShareTools && kagentURL != "" && httpClient != nil {
54+
createTool, err := tools.NewCreateShareLinkTool(httpClient, kagentURL, appName)
55+
if err != nil {
56+
return runner.Config{}, nil, fmt.Errorf("failed to create create_share_link tool: %w", err)
57+
}
58+
listTool, err := tools.NewListShareLinksTool(httpClient, kagentURL, appName)
59+
if err != nil {
60+
return runner.Config{}, nil, fmt.Errorf("failed to create list_share_links tool: %w", err)
61+
}
62+
deleteTool, err := tools.NewDeleteShareLinkTool(httpClient, kagentURL, appName)
63+
if err != nil {
64+
return runner.Config{}, nil, fmt.Errorf("failed to create delete_share_link tool: %w", err)
65+
}
66+
extraTools = append(extraTools, createTool, listTool, deleteTool)
67+
log.Info("Share link tools enabled")
68+
}
69+
4970
stsPlugin, err := buildTokenPropagationPlugin(ctx, log)
5071
if err != nil {
5172
return runner.Config{}, nil, err

go/adk/pkg/tools/share_tools.go

Lines changed: 205 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,205 @@
1+
package tools
2+
3+
import (
4+
"context"
5+
"encoding/json"
6+
"fmt"
7+
"io"
8+
"net/http"
9+
"net/url"
10+
"os"
11+
"strings"
12+
13+
"google.golang.org/adk/tool"
14+
"google.golang.org/adk/tool/functiontool"
15+
)
16+
17+
// shareClient holds the dependencies for share link tools, captured at construction time.
18+
type shareClient struct {
19+
baseURL string
20+
uiURL string // KAGENT_UI_URL, used to build full share URLs
21+
appName string
22+
httpClient *http.Client
23+
}
24+
25+
// parseAppName converts a Python-identifier app_name back to (namespace, name).
26+
// Format: "namespace__NS__agent_name" with hyphens encoded as underscores.
27+
func parseAppName(appName string) (namespace, name string) {
28+
parts := strings.SplitN(appName, "__NS__", 2)
29+
if len(parts) != 2 {
30+
return "", strings.ReplaceAll(appName, "_", "-")
31+
}
32+
return strings.ReplaceAll(parts[0], "_", "-"), strings.ReplaceAll(parts[1], "_", "-")
33+
}
34+
35+
// shareURL returns the share URL for a session token.
36+
// With uiURL set it returns a full absolute URL; otherwise a relative path.
37+
func (c *shareClient) shareURL(token, sessionID string) string {
38+
ns, name := parseAppName(c.appName)
39+
path := fmt.Sprintf("/agents/%s/%s/chat/%s?share=%s", ns, name, sessionID, token)
40+
if c.uiURL != "" {
41+
return c.uiURL + path
42+
}
43+
return path
44+
}
45+
46+
func (c *shareClient) do(ctx context.Context, method, path string) (*http.Response, error) {
47+
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, nil)
48+
if err != nil {
49+
return nil, fmt.Errorf("building request %s %s: %w", method, c.baseURL+path, err)
50+
}
51+
req.Header.Set("X-Agent-Name", c.appName)
52+
return c.httpClient.Do(req)
53+
}
54+
55+
func (c *shareClient) doWithJSON(ctx context.Context, method, path string, body io.Reader) (*http.Response, error) {
56+
req, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body)
57+
if err != nil {
58+
return nil, fmt.Errorf("building request %s %s: %w", method, c.baseURL+path, err)
59+
}
60+
req.Header.Set("X-Agent-Name", c.appName)
61+
req.Header.Set("Content-Type", "application/json")
62+
return c.httpClient.Do(req)
63+
}
64+
65+
func (c *shareClient) readBody(resp *http.Response) (map[string]any, error) {
66+
defer resp.Body.Close()
67+
body, err := io.ReadAll(resp.Body)
68+
if err != nil {
69+
return nil, fmt.Errorf("reading response: %w", err)
70+
}
71+
var out map[string]any
72+
if err := json.Unmarshal(body, &out); err != nil {
73+
return nil, fmt.Errorf("decoding response: %w", err)
74+
}
75+
return out, nil
76+
}
77+
78+
type createShareInput struct {
79+
// ReadOnly controls whether the shared link allows visitors to send messages.
80+
// When nil (not provided by the model), the server defaults to true (read-only).
81+
ReadOnly *bool `json:"read_only,omitempty"`
82+
}
83+
84+
// NewCreateShareLinkTool creates a tool that generates a share token for the current session.
85+
func NewCreateShareLinkTool(httpClient *http.Client, baseURL, appName string) (tool.Tool, error) {
86+
c := &shareClient{
87+
baseURL: baseURL,
88+
uiURL: strings.TrimRight(os.Getenv("KAGENT_UI_URL"), "/"),
89+
appName: appName,
90+
httpClient: httpClient,
91+
}
92+
return functiontool.New(functiontool.Config{
93+
Name: "create_share_link",
94+
Description: "Creates a share link for the current chat session. " +
95+
"Returns a URL any authenticated user can open to view this session. " +
96+
"The link is read-only by default (visitors cannot send messages). " +
97+
"Set read_only=false to allow visitors to interact. " +
98+
"Each call creates a new token; existing tokens remain valid.",
99+
}, func(ctx tool.Context, in createShareInput) (map[string]any, error) {
100+
sessionID := ctx.SessionID()
101+
if sessionID == "" {
102+
return nil, fmt.Errorf("create_share_link: no session ID in context")
103+
}
104+
reqBody, err := json.Marshal(in)
105+
if err != nil {
106+
return nil, fmt.Errorf("create_share_link: encoding request: %w", err)
107+
}
108+
resp, err := c.doWithJSON(ctx, http.MethodPost, "/api/sessions/"+url.PathEscape(sessionID)+"/shares", strings.NewReader(string(reqBody)))
109+
if err != nil {
110+
return nil, fmt.Errorf("create_share_link: request failed: %w", err)
111+
}
112+
if resp.StatusCode != http.StatusCreated {
113+
_, _ = io.Copy(io.Discard, resp.Body)
114+
_ = resp.Body.Close()
115+
return nil, fmt.Errorf("create_share_link: unexpected status %d", resp.StatusCode)
116+
}
117+
body, err := c.readBody(resp)
118+
if err != nil {
119+
return nil, fmt.Errorf("create_share_link: %w", err)
120+
}
121+
data, _ := body["data"].(map[string]any)
122+
token, _ := data["token"].(string)
123+
readOnly, _ := data["read_only"].(bool)
124+
return map[string]any{
125+
"url": c.shareURL(token, sessionID),
126+
"read_only": readOnly,
127+
}, nil
128+
})
129+
}
130+
131+
// NewListShareLinksTool creates a tool that lists active share tokens for the current session.
132+
func NewListShareLinksTool(httpClient *http.Client, baseURL, appName string) (tool.Tool, error) {
133+
c := &shareClient{
134+
baseURL: baseURL,
135+
uiURL: strings.TrimRight(os.Getenv("KAGENT_UI_URL"), "/"),
136+
appName: appName,
137+
httpClient: httpClient,
138+
}
139+
return functiontool.New(functiontool.Config{
140+
Name: "list_share_links",
141+
Description: "Lists all active share links for the current session. " +
142+
"Returns each share token and creation time. " +
143+
"Use this to find a token before calling delete_share_link.",
144+
}, func(ctx tool.Context, _ struct{}) (map[string]any, error) {
145+
sessionID := ctx.SessionID()
146+
if sessionID == "" {
147+
return nil, fmt.Errorf("list_share_links: no session ID in context")
148+
}
149+
resp, err := c.do(ctx, http.MethodGet, "/api/sessions/"+url.PathEscape(sessionID)+"/shares")
150+
if err != nil {
151+
return nil, fmt.Errorf("list_share_links: request failed: %w", err)
152+
}
153+
if resp.StatusCode != http.StatusOK {
154+
_, _ = io.Copy(io.Discard, resp.Body)
155+
_ = resp.Body.Close()
156+
return nil, fmt.Errorf("list_share_links: unexpected status %d", resp.StatusCode)
157+
}
158+
body, err := c.readBody(resp)
159+
if err != nil {
160+
return nil, fmt.Errorf("list_share_links: %w", err)
161+
}
162+
shares := body["data"]
163+
if shares == nil {
164+
shares = []any{}
165+
}
166+
return map[string]any{"shares": shares}, nil
167+
})
168+
}
169+
170+
type deleteShareInput struct {
171+
Token string `json:"token"`
172+
}
173+
174+
// NewDeleteShareLinkTool creates a tool that revokes a specific share token for the current session.
175+
func NewDeleteShareLinkTool(httpClient *http.Client, baseURL, appName string) (tool.Tool, error) {
176+
c := &shareClient{
177+
baseURL: baseURL,
178+
uiURL: strings.TrimRight(os.Getenv("KAGENT_UI_URL"), "/"),
179+
appName: appName,
180+
httpClient: httpClient,
181+
}
182+
return functiontool.New(functiontool.Config{
183+
Name: "delete_share_link",
184+
Description: "Deletes a share link by token, immediately revoking access for anyone using it. " +
185+
"Use list_share_links first to find the token you want to revoke.",
186+
}, func(ctx tool.Context, in deleteShareInput) (map[string]any, error) {
187+
if in.Token == "" {
188+
return nil, fmt.Errorf("delete_share_link: token is required")
189+
}
190+
sessionID := ctx.SessionID()
191+
if sessionID == "" {
192+
return nil, fmt.Errorf("delete_share_link: no session ID in context")
193+
}
194+
path := "/api/sessions/" + url.PathEscape(sessionID) + "/shares/" + url.PathEscape(in.Token)
195+
resp, err := c.do(ctx, http.MethodDelete, path)
196+
if err != nil {
197+
return nil, fmt.Errorf("delete_share_link: request failed: %w", err)
198+
}
199+
defer resp.Body.Close()
200+
if resp.StatusCode != http.StatusOK {
201+
return nil, fmt.Errorf("delete_share_link: unexpected status %d", resp.StatusCode)
202+
}
203+
return map[string]any{"status": "revoked", "token": in.Token}, nil
204+
})
205+
}

0 commit comments

Comments
 (0)