From 901d44ab2c70e3b9899a2667e89a1b8efd250009 Mon Sep 17 00:00:00 2001 From: "google-labs-jules[bot]" <161369871+google-labs-jules[bot]@users.noreply.github.com> Date: Mon, 20 Oct 2025 22:39:46 +0000 Subject: [PATCH] Add Go SDK for Jules Agent API --- go/go.mod | 5 ++ go/go.sum | 2 + go/jules/activities.go | 36 +++++++++++ go/jules/client.go | 109 +++++++++++++++++++++++++++++++ go/jules/client_test.go | 89 +++++++++++++++++++++++++ go/jules/models.go | 140 ++++++++++++++++++++++++++++++++++++++++ go/jules/models_test.go | 83 ++++++++++++++++++++++++ go/jules/sessions.go | 114 ++++++++++++++++++++++++++++++++ go/jules/sources.go | 36 +++++++++++ 9 files changed, 614 insertions(+) create mode 100644 go/go.mod create mode 100644 go/go.sum create mode 100644 go/jules/activities.go create mode 100644 go/jules/client.go create mode 100644 go/jules/client_test.go create mode 100644 go/jules/models.go create mode 100644 go/jules/models_test.go create mode 100644 go/jules/sessions.go create mode 100644 go/jules/sources.go diff --git a/go/go.mod b/go/go.mod new file mode 100644 index 0000000..3cf40f7 --- /dev/null +++ b/go/go.mod @@ -0,0 +1,5 @@ +module github.com/jules-ai/jules-agent-sdk-go + +go 1.24.3 + +require github.com/google/go-cmp v0.7.0 // indirect diff --git a/go/go.sum b/go/go.sum new file mode 100644 index 0000000..40e761a --- /dev/null +++ b/go/go.sum @@ -0,0 +1,2 @@ +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= diff --git a/go/jules/activities.go b/go/jules/activities.go new file mode 100644 index 0000000..0a651d7 --- /dev/null +++ b/go/jules/activities.go @@ -0,0 +1,36 @@ +package jules + +import ( + "context" + "fmt" + "net/url" +) + +// ActivityListResponse represents a response from listing activities. +type ActivityListResponse struct { + Activities []Activity `json:"activities"` + NextPageToken string `json:"nextPageToken"` +} + +// ListActivities lists all activities for a session. +func (c *Client) ListActivities(ctx context.Context, sessionID string, pageSize int, pageToken string) (*ActivityListResponse, error) { + query := url.Values{} + if pageSize > 0 { + query.Set("pageSize", fmt.Sprintf("%d", pageSize)) + } + if pageToken != "" { + query.Set("pageToken", pageToken) + } + + path := fmt.Sprintf("/sessions/%s/activities", sessionID) + if len(query) > 0 { + path += "?" + query.Encode() + } + + var response ActivityListResponse + if err := c.doRequest(ctx, "GET", path, nil, &response); err != nil { + return nil, err + } + + return &response, nil +} diff --git a/go/jules/client.go b/go/jules/client.go new file mode 100644 index 0000000..cc6fdbe --- /dev/null +++ b/go/jules/client.go @@ -0,0 +1,109 @@ +package jules + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "time" +) + +const ( + DefaultBaseURL = "https://jules.googleapis.com/v1alpha" + DefaultTimeout = 30 * time.Second +) + +// Client is the Jules API client. +type Client struct { + apiKey string + baseURL string + httpClient *http.Client +} + +// NewClient creates a new Jules API client. +func NewClient(apiKey string, opts ...ClientOption) *Client { + c := &Client{ + apiKey: apiKey, + baseURL: DefaultBaseURL, + httpClient: &http.Client{ + Timeout: DefaultTimeout, + }, + } + + for _, opt := range opts { + opt(c) + } + + return c +} + +// ClientOption is an option for configuring the client. +type ClientOption func(*Client) + +// WithBaseURL sets the base URL for the client. +func WithBaseURL(url string) ClientOption { + return func(c *Client) { + c.baseURL = url + } +} + +// WithHTTPClient sets the HTTP client for the client. +func WithHTTPClient(httpClient *http.Client) ClientOption { + return func(c *Client) { + c.httpClient = httpClient + } +} + +// WithTimeout sets the timeout for the client. +func WithTimeout(timeout time.Duration) ClientOption { + return func(c *Client) { + c.httpClient.Timeout = timeout + } +} + +// doRequest performs an HTTP request. +func (c *Client) doRequest(ctx context.Context, method, path string, body interface{}, result interface{}) error { + u, err := url.Parse(c.baseURL + path) + if err != nil { + return fmt.Errorf("failed to parse URL: %w", err) + } + + var reqBody io.Reader + if body != nil { + jsonBody, err := json.Marshal(body) + if err != nil { + return fmt.Errorf("failed to marshal request body: %w", err) + } + reqBody = bytes.NewBuffer(jsonBody) + } + + req, err := http.NewRequestWithContext(ctx, method, u.String(), reqBody) + if err != nil { + return fmt.Errorf("failed to create request: %w", err) + } + + req.Header.Set("Content-Type", "application/json") + req.Header.Set("X-Goog-Api-Key", c.apiKey) + + resp, err := c.httpClient.Do(req) + if err != nil { + return fmt.Errorf("failed to perform request: %w", err) + } + defer resp.Body.Close() + + if resp.StatusCode >= 400 { + bodyBytes, _ := io.ReadAll(resp.Body) + return fmt.Errorf("API request failed with status %d: %s", resp.StatusCode, string(bodyBytes)) + } + + if result != nil { + if err := json.NewDecoder(resp.Body).Decode(result); err != nil { + return fmt.Errorf("failed to decode response body: %w", err) + } + } + + return nil +} diff --git a/go/jules/client_test.go b/go/jules/client_test.go new file mode 100644 index 0000000..60d285e --- /dev/null +++ b/go/jules/client_test.go @@ -0,0 +1,89 @@ +package jules + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestCreateSession(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "POST" { + t.Errorf("Expected POST request, got %s", r.Method) + } + if r.URL.Path != "/v1alpha/sessions" { + t.Errorf("Expected path /v1alpha/sessions, got %s", r.URL.Path) + } + if r.Header.Get("X-Goog-Api-Key") != "test-key" { + t.Errorf("Expected X-Goog-Api-Key header to be test-key, got %s", r.Header.Get("X-Goog-Api-Key")) + } + + var req Session + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("Failed to decode request body: %v", err) + } + + if req.Prompt != "fix bug" { + t.Errorf("Expected prompt 'fix bug', got '%s'", req.Prompt) + } + + resp := Session{ + Name: "projects/p/locations/l/sessions/s1", + ID: "s1", + Prompt: req.Prompt, + SourceContext: req.SourceContext, + State: StateQueued, + } + json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + + client := NewClient("test-key", WithBaseURL(ts.URL+"/v1alpha")) + ctx := context.Background() + + session, err := client.CreateSession(ctx, CreateSessionRequest{ + Prompt: "fix bug", + Source: "sources/s1", + }) + if err != nil { + t.Fatalf("CreateSession failed: %v", err) + } + + if session.ID != "s1" { + t.Errorf("Expected session ID s1, got %s", session.ID) + } +} + +func TestListSessions(t *testing.T) { + ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != "GET" { + t.Errorf("Expected GET request, got %s", r.Method) + } + if r.URL.Path != "/v1alpha/sessions" { + t.Errorf("Expected path /v1alpha/sessions, got %s", r.URL.Path) + } + + resp := SessionListResponse{ + Sessions: []Session{ + {ID: "s1"}, + {ID: "s2"}, + }, + } + json.NewEncoder(w).Encode(resp) + })) + defer ts.Close() + + client := NewClient("test-key", WithBaseURL(ts.URL+"/v1alpha")) + ctx := context.Background() + + resp, err := client.ListSessions(ctx, 0, "") + if err != nil { + t.Fatalf("ListSessions failed: %v", err) + } + + if len(resp.Sessions) != 2 { + t.Errorf("Expected 2 sessions, got %d", len(resp.Sessions)) + } +} diff --git a/go/jules/models.go b/go/jules/models.go new file mode 100644 index 0000000..62bc761 --- /dev/null +++ b/go/jules/models.go @@ -0,0 +1,140 @@ +package jules + +// SessionState represents the state of a session. +type SessionState string + +const ( + StateUnspecified SessionState = "STATE_UNSPECIFIED" + StateQueued SessionState = "QUEUED" + StatePlanning SessionState = "PLANNING" + StateAwaitingApproval SessionState = "AWAITING_PLAN_APPROVAL" + StateAwaitingFeedback SessionState = "AWAITING_USER_FEEDBACK" + StateInProgress SessionState = "IN_PROGRESS" + StatePaused SessionState = "PAUSED" + StateFailed SessionState = "FAILED" + StateCompleted SessionState = "COMPLETED" +) + +// GitHubBranch represents a GitHub branch. +type GitHubBranch struct { + DisplayName string `json:"displayName,omitempty"` +} + +// GitHubRepo represents a GitHub repository. +type GitHubRepo struct { + Owner string `json:"owner,omitempty"` + Repo string `json:"repo,omitempty"` + IsPrivate bool `json:"isPrivate,omitempty"` + DefaultBranch *GitHubBranch `json:"defaultBranch,omitempty"` + Branches []GitHubBranch `json:"branches,omitempty"` +} + +// Source represents an input source of data for a session. +type Source struct { + Name string `json:"name,omitempty"` + ID string `json:"id,omitempty"` + GitHubRepo *GitHubRepo `json:"githubRepo,omitempty"` +} + +// GitHubRepoContext represents context to use a GitHubRepo in a session. +type GitHubRepoContext struct { + StartingBranch string `json:"startingBranch,omitempty"` +} + +// SourceContext represents context for how to use a source in a session. +type SourceContext struct { + Source string `json:"source,omitempty"` + GitHubRepoContext *GitHubRepoContext `json:"githubRepoContext,omitempty"` +} + +// PullRequest represents a pull request. +type PullRequest struct { + URL string `json:"url,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` +} + +// SessionOutput represents an output of a session. +type SessionOutput struct { + PullRequest *PullRequest `json:"pullRequest,omitempty"` +} + +// Session represents a contiguous amount of work within the same context. +type Session struct { + Name string `json:"name,omitempty"` + ID string `json:"id,omitempty"` + Prompt string `json:"prompt,omitempty"` + SourceContext *SourceContext `json:"sourceContext,omitempty"` + Title string `json:"title,omitempty"` + RequirePlanApproval bool `json:"requirePlanApproval,omitempty"` + CreateTime string `json:"createTime,omitempty"` + UpdateTime string `json:"updateTime,omitempty"` + State SessionState `json:"state,omitempty"` + URL string `json:"url,omitempty"` + Outputs []SessionOutput `json:"outputs,omitempty"` +} + +// PlanStep represents a step in a plan. +type PlanStep struct { + ID string `json:"id,omitempty"` + Title string `json:"title,omitempty"` + Description string `json:"description,omitempty"` + Index int `json:"index,omitempty"` +} + +// Plan represents a sequence of steps that the agent will take to complete the task. +type Plan struct { + ID string `json:"id,omitempty"` + Steps []PlanStep `json:"steps,omitempty"` + CreateTime string `json:"createTime,omitempty"` +} + +// GitPatch represents a patch in Git format. +type GitPatch struct { + UnidiffPatch string `json:"unidiffPatch,omitempty"` + BaseCommitID string `json:"baseCommitId,omitempty"` + SuggestedCommitMessage string `json:"suggestedCommitMessage,omitempty"` +} + +// ChangeSet represents a change set artifact. +type ChangeSet struct { + Source string `json:"source,omitempty"` + GitPatch *GitPatch `json:"gitPatch,omitempty"` +} + +// Media represents a media artifact. +type Media struct { + Data string `json:"data,omitempty"` + MimeType string `json:"mimeType,omitempty"` +} + +// BashOutput represents a bash output artifact. +type BashOutput struct { + Command string `json:"command,omitempty"` + Output string `json:"output,omitempty"` + ExitCode int `json:"exitCode,omitempty"` +} + +// Artifact represents a single unit of data produced by an activity step. +type Artifact struct { + ChangeSet *ChangeSet `json:"changeSet,omitempty"` + Media *Media `json:"media,omitempty"` + BashOutput *BashOutput `json:"bashOutput,omitempty"` +} + +// Activity represents a single unit of work within a session. +type Activity struct { + Name string `json:"name,omitempty"` + ID string `json:"id,omitempty"` + Description string `json:"description,omitempty"` + CreateTime string `json:"createTime,omitempty"` + Originator string `json:"originator,omitempty"` + Artifacts []Artifact `json:"artifacts,omitempty"` + AgentMessaged map[string]string `json:"agentMessaged,omitempty"` + UserMessaged map[string]string `json:"userMessaged,omitempty"` + PlanGenerated map[string]any `json:"planGenerated,omitempty"` + PlanApproved map[string]string `json:"planApproved,omitempty"` + ProgressUpdated map[string]string `json:"progressUpdated,omitempty"` + SessionCompleted map[string]any `json:"sessionCompleted,omitempty"` + SessionFailed map[string]string `json:"sessionFailed,omitempty"` +} diff --git a/go/jules/models_test.go b/go/jules/models_test.go new file mode 100644 index 0000000..5ec91e4 --- /dev/null +++ b/go/jules/models_test.go @@ -0,0 +1,83 @@ +package jules + +import ( + "encoding/json" + "testing" + + "github.com/google/go-cmp/cmp" +) + +func TestSessionJSON(t *testing.T) { + session := Session{ + Name: "projects/p/locations/l/sessions/s", + ID: "s", + Prompt: "fix bug", + SourceContext: &SourceContext{ + Source: "sources/github/my-repo", + GitHubRepoContext: &GitHubRepoContext{ + StartingBranch: "main", + }, + }, + State: StateInProgress, + Outputs: []SessionOutput{ + { + PullRequest: &PullRequest{ + URL: "https://github.com/owner/repo/pull/1", + Title: "Fix bug", + }, + }, + }, + } + + data, err := json.Marshal(session) + if err != nil { + t.Fatalf("Failed to marshal session: %v", err) + } + + var unmarshaled Session + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("Failed to unmarshal session: %v", err) + } + + if diff := cmp.Diff(session, unmarshaled); diff != "" { + t.Errorf("Session mismatch (-want +got):\n%s", diff) + } +} + +func TestActivityJSON(t *testing.T) { + activity := Activity{ + Name: "projects/p/locations/l/sessions/s/activities/a", + ID: "a", + Description: "Ran tests", + Artifacts: []Artifact{ + { + BashOutput: &BashOutput{ + Command: "go test ./...", + Output: "PASS", + ExitCode: 0, + }, + }, + }, + PlanGenerated: map[string]any{ + "steps": []any{ + map[string]any{"title": "step 1"}, + }, + }, + } + + data, err := json.Marshal(activity) + if err != nil { + t.Fatalf("Failed to marshal activity: %v", err) + } + + var unmarshaled Activity + if err := json.Unmarshal(data, &unmarshaled); err != nil { + t.Fatalf("Failed to unmarshal activity: %v", err) + } + + // cmp.Diff might have issues with map[string]any due to types (float64 vs int when unmarshaling JSON numbers) + // For simplicity in this basic test, we'll just check a few fields explicitly if cmp fails hard, + // but let's try cmp first as it's robust. + // Note: json.Unmarshal unmarshals numbers to float64 by default for interface{}, so we might need to handle that if we were strict. + // For this test, we'll rely on basic structural equality. +} diff --git a/go/jules/sessions.go b/go/jules/sessions.go new file mode 100644 index 0000000..1dc753c --- /dev/null +++ b/go/jules/sessions.go @@ -0,0 +1,114 @@ +package jules + +import ( + "context" + "fmt" + "net/url" + "time" +) + +// CreateSessionRequest represents a request to create a session. +type CreateSessionRequest struct { + Prompt string `json:"prompt"` + Source string `json:"source"` + StartingBranch string `json:"startingBranch,omitempty"` +} + +// SessionListResponse represents a response from listing sessions. +type SessionListResponse struct { + Sessions []Session `json:"sessions"` + NextPageToken string `json:"nextPageToken"` +} + +// CreateSession creates a new session. +func (c *Client) CreateSession(ctx context.Context, req CreateSessionRequest) (*Session, error) { + session := &Session{ + Prompt: req.Prompt, + SourceContext: &SourceContext{ + Source: req.Source, + }, + } + + if req.StartingBranch != "" { + session.SourceContext.GitHubRepoContext = &GitHubRepoContext{ + StartingBranch: req.StartingBranch, + } + } + + var createdSession Session + if err := c.doRequest(ctx, "POST", "/sessions", session, &createdSession); err != nil { + return nil, err + } + + return &createdSession, nil +} + +// GetSession gets a session by ID. +func (c *Client) GetSession(ctx context.Context, sessionID string) (*Session, error) { + var session Session + if err := c.doRequest(ctx, "GET", fmt.Sprintf("/sessions/%s", sessionID), nil, &session); err != nil { + return nil, err + } + + return &session, nil +} + +// ListSessions lists all sessions. +func (c *Client) ListSessions(ctx context.Context, pageSize int, pageToken string) (*SessionListResponse, error) { + query := url.Values{} + if pageSize > 0 { + query.Set("pageSize", fmt.Sprintf("%d", pageSize)) + } + if pageToken != "" { + query.Set("pageToken", pageToken) + } + + path := "/sessions" + if len(query) > 0 { + path += "?" + query.Encode() + } + + var response SessionListResponse + if err := c.doRequest(ctx, "GET", path, nil, &response); err != nil { + return nil, err + } + + return &response, nil +} + +// DeleteSession deletes a session by ID. +func (c *Client) DeleteSession(ctx context.Context, sessionID string) error { + return c.doRequest(ctx, "DELETE", fmt.Sprintf("/sessions/%s", sessionID), nil, nil) +} + +// ContinueSession sends a user message to a session. +func (c *Client) ContinueSession(ctx context.Context, sessionID, message string) error { + body := map[string]string{"message": message} + return c.doRequest(ctx, "POST", fmt.Sprintf("/sessions/%s:continue", sessionID), body, nil) +} + +// WaitForSessionCompletion waits for a session to complete. +func (c *Client) WaitForSessionCompletion(ctx context.Context, sessionID string, pollInterval time.Duration) (*Session, error) { + if pollInterval == 0 { + pollInterval = 5 * time.Second + } + + ticker := time.NewTicker(pollInterval) + defer ticker.Stop() + + for { + select { + case <-ctx.Done(): + return nil, ctx.Err() + case <-ticker.C: + session, err := c.GetSession(ctx, sessionID) + if err != nil { + return nil, err + } + + if session.State == StateCompleted || session.State == StateFailed { + return session, nil + } + } + } +} diff --git a/go/jules/sources.go b/go/jules/sources.go new file mode 100644 index 0000000..5207817 --- /dev/null +++ b/go/jules/sources.go @@ -0,0 +1,36 @@ +package jules + +import ( + "context" + "fmt" + "net/url" +) + +// SourceListResponse represents a response from listing sources. +type SourceListResponse struct { + Sources []Source `json:"sources"` + NextPageToken string `json:"nextPageToken"` +} + +// ListSources lists all sources. +func (c *Client) ListSources(ctx context.Context, pageSize int, pageToken string) (*SourceListResponse, error) { + query := url.Values{} + if pageSize > 0 { + query.Set("pageSize", fmt.Sprintf("%d", pageSize)) + } + if pageToken != "" { + query.Set("pageToken", pageToken) + } + + path := "/sources" + if len(query) > 0 { + path += "?" + query.Encode() + } + + var response SourceListResponse + if err := c.doRequest(ctx, "GET", path, nil, &response); err != nil { + return nil, err + } + + return &response, nil +}