molecule-core/platform/cmd/cli/client.go
Hongming Wang 24fec62d7f initial commit — Molecule AI platform
Forked clean from public hackathon repo (Starfire-AgentTeam, BSL 1.1)
with full rebrand to Molecule AI under github.com/Molecule-AI/molecule-monorepo.

Brand: Starfire → Molecule AI.
Slug: starfire / agent-molecule → molecule.
Env vars: STARFIRE_* → MOLECULE_*.
Go module: github.com/agent-molecule/platform → github.com/Molecule-AI/molecule-monorepo/platform.
Python packages: starfire_plugin → molecule_plugin, starfire_agent → molecule_agent.
DB: agentmolecule → molecule.

History truncated; see public repo for prior commits and contributor
attribution. Verified green: go test -race ./... (platform), pytest
(workspace-template 1129 + sdk 132), vitest (canvas 352), build (mcp).

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
2026-04-13 11:55:37 -07:00

756 lines
25 KiB
Go

package main
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"time"
)
// WorkspaceInfo represents a workspace from the platform API.
type WorkspaceInfo struct {
ID string `json:"id"`
Name string `json:"name"`
Role *string `json:"role"`
Tier int `json:"tier"`
Status string `json:"status"`
URL string `json:"url"`
ParentID *string `json:"parent_id"`
AgentCard json.RawMessage `json:"agent_card"`
ActiveTasks int `json:"active_tasks"`
LastErrorRate float64 `json:"last_error_rate"`
LastSampleError string `json:"last_sample_error"`
UptimeSeconds int `json:"uptime_seconds"`
// Phase 30 — surface the runtime so molecli can flag remote agents
// (runtime='external') distinctly from local Docker workspaces.
Runtime string `json:"runtime"`
}
// AgentCardInfo represents parsed fields from the agent_card JSON.
type AgentCardInfo struct {
Name string `json:"name"`
Description string `json:"description"`
URL string `json:"url"`
Skills []SkillInfo `json:"skills"`
}
// SkillInfo is a skill entry in an agent card.
type SkillInfo struct {
ID string `json:"id"`
Name string `json:"name"`
}
// EventInfo represents a structure event from the platform API.
type EventInfo struct {
ID string `json:"id"`
EventType string `json:"event_type"`
WorkspaceID *string `json:"workspace_id"`
Payload json.RawMessage `json:"payload"`
CreatedAt time.Time `json:"created_at"`
}
// WorkspaceFile represents a file read through the Files API.
type WorkspaceFile struct {
Path string `json:"path"`
Content string `json:"content"`
Size int `json:"size"`
}
// SessionSearchItem represents a session search result from the platform API.
type SessionSearchItem struct {
Kind string `json:"kind"`
ID string `json:"id"`
WorkspaceID string `json:"workspace_id"`
Label string `json:"label"`
Content string `json:"content"`
Method string `json:"method"`
Status string `json:"status"`
RequestBody json.RawMessage `json:"request_body,omitempty"`
ResponseBody json.RawMessage `json:"response_body,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// WorkspaceBundle represents the exported bundle payload from the platform API.
type WorkspaceBundle struct {
Schema string `json:"schema"`
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Tier int `json:"tier"`
Model string `json:"model"`
SystemPrompt string `json:"system_prompt"`
Skills []WorkspaceBundleSkill `json:"skills"`
Prompts map[string]string `json:"prompts"`
SubWorkspaces []WorkspaceBundle `json:"sub_workspaces"`
}
// WorkspaceBundleSkill is a serialized skill entry from a bundle export.
type WorkspaceBundleSkill struct {
ID string `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Files map[string]string `json:"files"`
}
// WSEvent represents a WebSocket event message.
type WSEvent struct {
Event string `json:"event"`
WorkspaceID string `json:"workspace_id"`
Timestamp time.Time `json:"timestamp"`
Payload json.RawMessage `json:"payload"`
}
// PlatformClient is an HTTP client for the platform API.
type PlatformClient struct {
baseURL string
httpClient *http.Client
}
// NewPlatformClient creates a new platform API client.
func NewPlatformClient(baseURL string) *PlatformClient {
return &PlatformClient{
baseURL: baseURL,
httpClient: &http.Client{
Timeout: 10 * time.Second,
},
}
}
// FetchWorkspaces fetches all workspaces from GET /workspaces.
func (c *PlatformClient) FetchWorkspaces() ([]WorkspaceInfo, error) {
endpoint, err := url.JoinPath(c.baseURL, "workspaces")
if err != nil {
return nil, fmt.Errorf("build workspaces URL: %w", err)
}
resp, err := c.httpClient.Get(endpoint)
if err != nil {
return nil, fmt.Errorf("fetch workspaces: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("fetch workspaces: status %d (body read error: %v)", resp.StatusCode, readErr)
}
return nil, fmt.Errorf("fetch workspaces: status %d: %s", resp.StatusCode, body)
}
var workspaces []WorkspaceInfo
if err := json.NewDecoder(resp.Body).Decode(&workspaces); err != nil {
return nil, fmt.Errorf("decode workspaces: %w", err)
}
return workspaces, nil
}
// FetchEvents fetches recent events from GET /events.
func (c *PlatformClient) FetchEvents() ([]EventInfo, error) {
endpoint, err := url.JoinPath(c.baseURL, "events")
if err != nil {
return nil, fmt.Errorf("build events URL: %w", err)
}
resp, err := c.httpClient.Get(endpoint)
if err != nil {
return nil, fmt.Errorf("fetch events: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("fetch events: status %d (body read error: %v)", resp.StatusCode, readErr)
}
return nil, fmt.Errorf("fetch events: status %d: %s", resp.StatusCode, body)
}
var events []EventInfo
if err := json.NewDecoder(resp.Body).Decode(&events); err != nil {
return nil, fmt.Errorf("decode events: %w", err)
}
return events, nil
}
// GetWorkspaceFile fetches a workspace file via GET /workspaces/:id/files/*path.
func (c *PlatformClient) GetWorkspaceFile(id, filePath string) (*WorkspaceFile, error) {
endpoint, err := url.JoinPath(c.baseURL, "workspaces", id, "files", filePath)
if err != nil {
return nil, fmt.Errorf("build file URL: %w", err)
}
resp, err := c.httpClient.Get(endpoint)
if err != nil {
return nil, fmt.Errorf("get file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("get file: status %d: %s", resp.StatusCode, body)
}
var file WorkspaceFile
if err := json.NewDecoder(resp.Body).Decode(&file); err != nil {
return nil, fmt.Errorf("decode file: %w", err)
}
return &file, nil
}
// PutWorkspaceFile writes a workspace file via PUT /workspaces/:id/files/*path.
func (c *PlatformClient) PutWorkspaceFile(id, filePath, content string) error {
endpoint, err := url.JoinPath(c.baseURL, "workspaces", id, "files", filePath)
if err != nil {
return fmt.Errorf("build file URL: %w", err)
}
body, err := json.Marshal(map[string]any{"content": content})
if err != nil {
return fmt.Errorf("marshal file request: %w", err)
}
req, err := http.NewRequest(http.MethodPut, endpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build file request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("put file: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("put file: status %d: %s", resp.StatusCode, body)
}
return nil
}
// SearchSession searches a workspace's activity logs and memories.
func (c *PlatformClient) SearchSession(id, query string, limit int) ([]SessionSearchItem, error) {
endpoint, err := url.JoinPath(c.baseURL, "workspaces", id, "session-search")
if err != nil {
return nil, fmt.Errorf("build session search URL: %w", err)
}
params := url.Values{}
if query != "" {
params.Set("q", query)
}
if limit > 0 {
params.Set("limit", fmt.Sprintf("%d", limit))
}
if encoded := params.Encode(); encoded != "" {
endpoint += "?" + encoded
}
resp, err := c.httpClient.Get(endpoint)
if err != nil {
return nil, fmt.Errorf("search session: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("search session: status %d (body read error: %v)", resp.StatusCode, readErr)
}
return nil, fmt.Errorf("search session: status %d: %s", resp.StatusCode, body)
}
var items []SessionSearchItem
if err := json.NewDecoder(resp.Body).Decode(&items); err != nil {
return nil, fmt.Errorf("decode session search: %w", err)
}
return items, nil
}
// ExportBundle fetches a workspace bundle via GET /bundles/export/:id.
func (c *PlatformClient) ExportBundle(id string) (*WorkspaceBundle, error) {
endpoint, err := url.JoinPath(c.baseURL, "bundles", "export", id)
if err != nil {
return nil, fmt.Errorf("build bundle export URL: %w", err)
}
resp, err := c.httpClient.Get(endpoint)
if err != nil {
return nil, fmt.Errorf("export bundle: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("export bundle: status %d (body read error: %v)", resp.StatusCode, readErr)
}
return nil, fmt.Errorf("export bundle: status %d: %s", resp.StatusCode, body)
}
var bundle WorkspaceBundle
if err := json.NewDecoder(resp.Body).Decode(&bundle); err != nil {
return nil, fmt.Errorf("decode bundle: %w", err)
}
return &bundle, nil
}
// DeleteWorkspace deletes a workspace via DELETE /workspaces/:id.
func (c *PlatformClient) DeleteWorkspace(id string) error {
endpoint, err := deleteURL(c.baseURL, id)
if err != nil {
return fmt.Errorf("build delete URL: %w", err)
}
req, err := http.NewRequest(http.MethodDelete, endpoint, nil)
if err != nil {
return fmt.Errorf("build delete request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("delete workspace: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("delete workspace: status %d (body read error: %v)", resp.StatusCode, readErr)
}
return fmt.Errorf("delete workspace: status %d: %s", resp.StatusCode, body)
}
return nil
}
// Request/response types for mutating operations.
// CreateWorkspaceRequest is the body for POST /workspaces.
type CreateWorkspaceRequest struct {
Name string `json:"name"`
Role string `json:"role,omitempty"`
Tier int `json:"tier,omitempty"`
ParentID string `json:"parent_id,omitempty"`
}
// CreateWorkspaceResponse is the response from POST /workspaces.
type CreateWorkspaceResponse struct {
ID string `json:"id"`
Status string `json:"status"`
}
// UpdateWorkspaceRequest is the body for PATCH /workspaces/:id (all fields optional).
type UpdateWorkspaceRequest struct {
Name *string `json:"name,omitempty"`
Role *string `json:"role,omitempty"`
Tier *int `json:"tier,omitempty"`
ParentID *string `json:"parent_id,omitempty"`
}
// DiscoverResponse is the response from GET /registry/discover/:id.
type DiscoverResponse struct {
ID string `json:"id"`
URL string `json:"url"`
Status string `json:"status,omitempty"`
}
// AccessResponse is the response from POST /registry/check-access.
type AccessResponse struct {
Allowed bool `json:"allowed"`
}
// GetWorkspace fetches a single workspace from GET /workspaces/:id.
func (c *PlatformClient) GetWorkspace(id string) (*WorkspaceInfo, error) {
endpoint, err := url.JoinPath(c.baseURL, "workspaces", id)
if err != nil {
return nil, fmt.Errorf("build workspace URL: %w", err)
}
resp, err := c.httpClient.Get(endpoint)
if err != nil {
return nil, fmt.Errorf("get workspace: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("get workspace: status %d (body read error: %v)", resp.StatusCode, readErr)
}
return nil, fmt.Errorf("get workspace: status %d: %s", resp.StatusCode, body)
}
var ws WorkspaceInfo
if err := json.NewDecoder(resp.Body).Decode(&ws); err != nil {
return nil, fmt.Errorf("decode workspace: %w", err)
}
return &ws, nil
}
// CreateWorkspace creates a new workspace via POST /workspaces.
func (c *PlatformClient) CreateWorkspace(req CreateWorkspaceRequest) (*CreateWorkspaceResponse, error) {
endpoint, err := url.JoinPath(c.baseURL, "workspaces")
if err != nil {
return nil, fmt.Errorf("build workspaces URL: %w", err)
}
body, err := json.Marshal(req)
if err != nil {
return nil, fmt.Errorf("marshal create request: %w", err)
}
resp, err := c.httpClient.Post(endpoint, "application/json", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("create workspace: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("create workspace: status %d (body read error: %v)", resp.StatusCode, readErr)
}
return nil, fmt.Errorf("create workspace: status %d: %s", resp.StatusCode, b)
}
var result CreateWorkspaceResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode create response: %w", err)
}
return &result, nil
}
// UpdateWorkspace updates a workspace via PATCH /workspaces/:id.
func (c *PlatformClient) UpdateWorkspace(id string, req UpdateWorkspaceRequest) error {
endpoint, err := url.JoinPath(c.baseURL, "workspaces", id)
if err != nil {
return fmt.Errorf("build workspace URL: %w", err)
}
body, err := json.Marshal(req)
if err != nil {
return fmt.Errorf("marshal update request: %w", err)
}
httpReq, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewReader(body))
if err != nil {
return fmt.Errorf("build update request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(httpReq)
if err != nil {
return fmt.Errorf("update workspace: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("update workspace: status %d (body read error: %v)", resp.StatusCode, readErr)
}
return fmt.Errorf("update workspace: status %d: %s", resp.StatusCode, b)
}
return nil
}
// FetchEventsByWorkspace fetches events for a specific workspace from GET /events/:workspaceId.
func (c *PlatformClient) FetchEventsByWorkspace(workspaceID string) ([]EventInfo, error) {
endpoint, err := url.JoinPath(c.baseURL, "events", workspaceID)
if err != nil {
return nil, fmt.Errorf("build events URL: %w", err)
}
resp, err := c.httpClient.Get(endpoint)
if err != nil {
return nil, fmt.Errorf("fetch events: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("fetch events: status %d (body read error: %v)", resp.StatusCode, readErr)
}
return nil, fmt.Errorf("fetch events: status %d: %s", resp.StatusCode, body)
}
var events []EventInfo
if err := json.NewDecoder(resp.Body).Decode(&events); err != nil {
return nil, fmt.Errorf("decode events: %w", err)
}
return events, nil
}
// DiscoverWorkspace calls GET /registry/discover/:id.
// callerID is optional; if non-empty it is sent as X-Workspace-ID.
func (c *PlatformClient) DiscoverWorkspace(id, callerID string) (*DiscoverResponse, error) {
endpoint, err := url.JoinPath(c.baseURL, "registry", "discover", id)
if err != nil {
return nil, fmt.Errorf("build discover URL: %w", err)
}
req, err := http.NewRequest(http.MethodGet, endpoint, nil)
if err != nil {
return nil, fmt.Errorf("build discover request: %w", err)
}
if callerID != "" {
req.Header.Set("X-Workspace-ID", callerID)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("discover workspace: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("discover workspace: status %d (body read error: %v)", resp.StatusCode, readErr)
}
return nil, fmt.Errorf("discover workspace: status %d: %s", resp.StatusCode, body)
}
var result DiscoverResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode discover response: %w", err)
}
return &result, nil
}
// GetPeers calls GET /registry/:id/peers.
func (c *PlatformClient) GetPeers(id string) ([]WorkspaceInfo, error) {
endpoint, err := url.JoinPath(c.baseURL, "registry", id, "peers")
if err != nil {
return nil, fmt.Errorf("build peers URL: %w", err)
}
resp, err := c.httpClient.Get(endpoint)
if err != nil {
return nil, fmt.Errorf("get peers: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("get peers: status %d (body read error: %v)", resp.StatusCode, readErr)
}
return nil, fmt.Errorf("get peers: status %d: %s", resp.StatusCode, body)
}
var peers []WorkspaceInfo
if err := json.NewDecoder(resp.Body).Decode(&peers); err != nil {
return nil, fmt.Errorf("decode peers: %w", err)
}
return peers, nil
}
// CheckAccess calls POST /registry/check-access.
func (c *PlatformClient) CheckAccess(callerID, targetID string) (*AccessResponse, error) {
endpoint, err := url.JoinPath(c.baseURL, "registry", "check-access")
if err != nil {
return nil, fmt.Errorf("build check-access URL: %w", err)
}
body, err := json.Marshal(map[string]string{"caller_id": callerID, "target_id": targetID})
if err != nil {
return nil, fmt.Errorf("marshal check-access request: %w", err)
}
resp, err := c.httpClient.Post(endpoint, "application/json", bytes.NewReader(body))
if err != nil {
return nil, fmt.Errorf("check access: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
b, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return nil, fmt.Errorf("check access: status %d (body read error: %v)", resp.StatusCode, readErr)
}
return nil, fmt.Errorf("check access: status %d: %s", resp.StatusCode, b)
}
var result AccessResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode check-access response: %w", err)
}
return &result, nil
}
// UpdateAgentCard updates an agent's card via POST /registry/update-card.
func (c *PlatformClient) UpdateAgentCard(workspaceID string, card json.RawMessage) error {
endpoint, err := url.JoinPath(c.baseURL, "registry", "update-card")
if err != nil {
return fmt.Errorf("build update-card URL: %w", err)
}
body, err := json.Marshal(map[string]any{
"workspace_id": workspaceID,
"agent_card": card,
})
if err != nil {
return fmt.Errorf("marshal update-card request: %w", err)
}
resp, err := c.httpClient.Post(endpoint, "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("update agent card: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, readErr := io.ReadAll(resp.Body)
if readErr != nil {
return fmt.Errorf("update agent card: status %d (body read error: %v)", resp.StatusCode, readErr)
}
return fmt.Errorf("update agent card: status %d: %s", resp.StatusCode, b)
}
return nil
}
// ── Config ────────────────────────────────────────────────────────────────────
// ConfigResponse is the response from GET /workspaces/:id/config.
type ConfigResponse struct {
Data json.RawMessage `json:"data"`
}
// GetConfig fetches the config for a workspace.
func (c *PlatformClient) GetConfig(id string) (json.RawMessage, error) {
endpoint, err := url.JoinPath(c.baseURL, "workspaces", id, "config")
if err != nil {
return nil, fmt.Errorf("build config URL: %w", err)
}
resp, err := c.httpClient.Get(endpoint)
if err != nil {
return nil, fmt.Errorf("get config: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("get config: status %d: %s", resp.StatusCode, body)
}
var result ConfigResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("decode config: %w", err)
}
return result.Data, nil
}
// PatchConfig merges patch into the workspace config (JSON merge patch semantics).
func (c *PlatformClient) PatchConfig(id string, patch json.RawMessage) error {
endpoint, err := url.JoinPath(c.baseURL, "workspaces", id, "config")
if err != nil {
return fmt.Errorf("build config URL: %w", err)
}
req, err := http.NewRequest(http.MethodPatch, endpoint, bytes.NewReader(patch))
if err != nil {
return fmt.Errorf("build config request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("patch config: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("patch config: status %d: %s", resp.StatusCode, body)
}
return nil
}
// ── Memory ────────────────────────────────────────────────────────────────────
// MemoryEntry is one entry from the workspace memory store.
type MemoryEntry struct {
Key string `json:"key"`
Value json.RawMessage `json:"value"`
ExpiresAt *time.Time `json:"expires_at,omitempty"`
UpdatedAt time.Time `json:"updated_at"`
}
// ListMemory fetches all memory entries for a workspace.
func (c *PlatformClient) ListMemory(id string) ([]MemoryEntry, error) {
endpoint, err := url.JoinPath(c.baseURL, "workspaces", id, "memory")
if err != nil {
return nil, fmt.Errorf("build memory URL: %w", err)
}
resp, err := c.httpClient.Get(endpoint)
if err != nil {
return nil, fmt.Errorf("list memory: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("list memory: status %d: %s", resp.StatusCode, body)
}
var entries []MemoryEntry
if err := json.NewDecoder(resp.Body).Decode(&entries); err != nil {
return nil, fmt.Errorf("decode memory: %w", err)
}
return entries, nil
}
// GetMemory fetches a single memory entry by key.
func (c *PlatformClient) GetMemory(id, key string) (*MemoryEntry, error) {
endpoint, err := url.JoinPath(c.baseURL, "workspaces", id, "memory", key)
if err != nil {
return nil, fmt.Errorf("build memory URL: %w", err)
}
resp, err := c.httpClient.Get(endpoint)
if err != nil {
return nil, fmt.Errorf("get memory: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("key %q not found", key)
}
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("get memory: status %d: %s", resp.StatusCode, body)
}
var entry MemoryEntry
if err := json.NewDecoder(resp.Body).Decode(&entry); err != nil {
return nil, fmt.Errorf("decode memory entry: %w", err)
}
return &entry, nil
}
// SetMemory upserts a memory entry. ttlSeconds=0 means no expiry.
func (c *PlatformClient) SetMemory(id, key string, value json.RawMessage, ttlSeconds int) error {
endpoint, err := url.JoinPath(c.baseURL, "workspaces", id, "memory")
if err != nil {
return fmt.Errorf("build memory URL: %w", err)
}
payload := map[string]any{"key": key, "value": value}
if ttlSeconds > 0 {
payload["ttl_seconds"] = ttlSeconds
}
body, err := json.Marshal(payload)
if err != nil {
return fmt.Errorf("marshal memory request: %w", err)
}
resp, err := c.httpClient.Post(endpoint, "application/json", bytes.NewReader(body))
if err != nil {
return fmt.Errorf("set memory: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
b, _ := io.ReadAll(resp.Body)
return fmt.Errorf("set memory: status %d: %s", resp.StatusCode, b)
}
return nil
}
// DeleteMemory deletes a memory entry by key.
func (c *PlatformClient) DeleteMemory(id, key string) error {
endpoint, err := url.JoinPath(c.baseURL, "workspaces", id, "memory", key)
if err != nil {
return fmt.Errorf("build memory URL: %w", err)
}
req, err := http.NewRequest(http.MethodDelete, endpoint, nil)
if err != nil {
return fmt.Errorf("build memory delete request: %w", err)
}
resp, err := c.httpClient.Do(req)
if err != nil {
return fmt.Errorf("delete memory: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode < 200 || resp.StatusCode >= 300 {
body, _ := io.ReadAll(resp.Body)
return fmt.Errorf("delete memory: status %d: %s", resp.StatusCode, body)
}
return nil
}
// ParseAgentCard parses the agent_card JSON into an AgentCardInfo.
func ParseAgentCard(raw json.RawMessage) *AgentCardInfo {
if len(raw) == 0 || string(raw) == "null" {
return nil
}
var card AgentCardInfo
if err := json.Unmarshal(raw, &card); err != nil {
return nil
}
return &card
}