feat: add plan/session/phase API client and PlanDispatcher
All checks were successful
Security Scan / security (push) Successful in 8s
Test / test (push) Successful in 5m15s

Add Go types and client methods for the PHP Agentic API:
- Plans: list, get, create, update status, archive
- Sessions: list, get, start, end, continue (multi-agent handoff)
- Phases: get, update status, add checkpoint
- Tasks: update status, toggle completion
- PlanDispatcher: polls active plans, starts sessions, routes work

Default API URL changed to https://api.lthn.sh (lab).
Health endpoint updated to /v1/health.

Co-Authored-By: Virgil <virgil@lethean.io>
This commit is contained in:
Snider 2026-03-04 13:58:55 +00:00
parent deb7021b93
commit ecd8d17a87
6 changed files with 1015 additions and 4 deletions

View file

@ -336,7 +336,7 @@ func (c *Client) CreateTask(ctx context.Context, task Task) (*Task, error) {
func (c *Client) Ping(ctx context.Context) error {
const op = "agentic.Client.Ping"
endpoint := c.BaseURL + "/api/health"
endpoint := c.BaseURL + "/v1/health"
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {

View file

@ -308,7 +308,7 @@ func TestClient_CompleteTask_Bad_EmptyID(t *testing.T) {
func TestClient_Ping_Good(t *testing.T) {
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
assert.Equal(t, "/api/health", r.URL.Path)
assert.Equal(t, "/v1/health", r.URL.Path)
w.WriteHeader(http.StatusOK)
}))
defer server.Close()

View file

@ -29,8 +29,10 @@ const configFileName = "agentic.yaml"
const envFileName = ".env"
// DefaultBaseURL is the default API endpoint if none is configured.
// Uses localhost for local dev; set AGENTIC_BASE_URL for production.
const DefaultBaseURL = "http://localhost:8080"
// Set AGENTIC_BASE_URL to override:
// - Lab: https://api.lthn.sh
// - Prod: https://api.lthn.ai
const DefaultBaseURL = "https://api.lthn.sh"
// LoadConfig loads the agentic configuration from the specified directory.
// It first checks for a .env file, then falls back to ~/.core/agentic.yaml.

197
plan_dispatcher.go Normal file
View file

@ -0,0 +1,197 @@
package agentic
import (
"context"
"time"
"forge.lthn.ai/core/go/pkg/log"
)
// PlanDispatcher orchestrates plan-based work by polling active plans,
// starting sessions, and routing work to agents. It wraps the existing
// agent registry, router, and allowance service alongside the API client.
type PlanDispatcher struct {
registry AgentRegistry
router TaskRouter
allowance *AllowanceService
client *Client
events EventEmitter
agentType string // e.g. "opus", "haiku", "codex"
}
// NewPlanDispatcher creates a PlanDispatcher for the given agent type.
func NewPlanDispatcher(
agentType string,
registry AgentRegistry,
router TaskRouter,
allowance *AllowanceService,
client *Client,
) *PlanDispatcher {
return &PlanDispatcher{
agentType: agentType,
registry: registry,
router: router,
allowance: allowance,
client: client,
}
}
// SetEventEmitter attaches an event emitter for lifecycle notifications.
func (pd *PlanDispatcher) SetEventEmitter(em EventEmitter) {
pd.events = em
}
func (pd *PlanDispatcher) emit(ctx context.Context, event Event) {
if pd.events != nil {
if event.Timestamp.IsZero() {
event.Timestamp = time.Now().UTC()
}
_ = pd.events.Emit(ctx, event)
}
}
// PlanDispatchLoop polls for active plans at the given interval and picks up
// the first plan with a pending or in-progress phase. It starts a session,
// marks the phase in-progress, and returns the plan + session for the caller
// to work on. Runs until context is cancelled.
func (pd *PlanDispatcher) PlanDispatchLoop(ctx context.Context, interval time.Duration) error {
const op = "PlanDispatcher.PlanDispatchLoop"
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
plan, session, err := pd.pickUpWork(ctx)
if err != nil {
_ = log.E(op, "failed to pick up work", err)
continue
}
if plan == nil {
continue // no work available
}
pd.emit(ctx, Event{
Type: EventTaskDispatched,
TaskID: plan.Slug,
AgentID: session.SessionID,
Payload: map[string]string{
"plan": plan.Slug,
"agent_type": pd.agentType,
},
})
}
}
}
// pickUpWork finds the first active plan with workable phases, starts a session,
// and marks the next phase in-progress. Returns nil if no work is available.
func (pd *PlanDispatcher) pickUpWork(ctx context.Context) (*Plan, *sessionStartResponse, error) {
const op = "PlanDispatcher.pickUpWork"
plans, err := pd.client.ListPlans(ctx, ListPlanOptions{Status: PlanActive})
if err != nil {
return nil, nil, log.E(op, "failed to list active plans", err)
}
for _, plan := range plans {
// Check agent allowance before taking work.
if pd.allowance != nil {
check, err := pd.allowance.Check(pd.agentType, "")
if err != nil || !check.Allowed {
continue
}
}
// Get full plan with phases.
fullPlan, err := pd.client.GetPlan(ctx, plan.Slug)
if err != nil {
_ = log.E(op, "failed to get plan "+plan.Slug, err)
continue
}
// Find the next workable phase.
phase := nextWorkablePhase(fullPlan.Phases)
if phase == nil {
continue
}
// Start session for this plan.
session, err := pd.client.StartSession(ctx, StartSessionRequest{
AgentType: pd.agentType,
PlanSlug: plan.Slug,
})
if err != nil {
_ = log.E(op, "failed to start session for "+plan.Slug, err)
continue
}
// Mark phase as in-progress.
if phase.Status == PhasePending {
if err := pd.client.UpdatePhaseStatus(ctx, plan.Slug, phase.Name, PhaseInProgress, ""); err != nil {
_ = log.E(op, "failed to update phase status", err)
}
}
// Record job start.
if pd.allowance != nil {
_ = pd.allowance.RecordUsage(UsageReport{
AgentID: pd.agentType,
JobID: plan.Slug,
Event: QuotaEventJobStarted,
Timestamp: time.Now().UTC(),
})
}
return fullPlan, session, nil
}
return nil, nil, nil
}
// CompleteWork ends a session and optionally marks the current phase as completed.
func (pd *PlanDispatcher) CompleteWork(ctx context.Context, planSlug, sessionID, phaseName string, summary string) error {
const op = "PlanDispatcher.CompleteWork"
// Mark phase completed.
if phaseName != "" {
if err := pd.client.UpdatePhaseStatus(ctx, planSlug, phaseName, PhaseCompleted, ""); err != nil {
_ = log.E(op, "failed to complete phase", err)
}
}
// End session.
if err := pd.client.EndSession(ctx, sessionID, "completed", summary); err != nil {
return log.E(op, "failed to end session", err)
}
// Record job completion.
if pd.allowance != nil {
_ = pd.allowance.RecordUsage(UsageReport{
AgentID: pd.agentType,
JobID: planSlug,
Event: QuotaEventJobCompleted,
Timestamp: time.Now().UTC(),
})
}
return nil
}
// nextWorkablePhase returns the first phase that is pending or in-progress.
func nextWorkablePhase(phases []Phase) *Phase {
for i := range phases {
switch phases[i].Status {
case PhasePending:
if phases[i].CanStart {
return &phases[i]
}
case PhaseInProgress:
return &phases[i]
}
}
return nil
}

525
plans.go Normal file
View file

@ -0,0 +1,525 @@
package agentic
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"forge.lthn.ai/core/go/pkg/log"
)
// PlanStatus represents the state of a plan.
type PlanStatus string
const (
PlanDraft PlanStatus = "draft"
PlanActive PlanStatus = "active"
PlanPaused PlanStatus = "paused"
PlanCompleted PlanStatus = "completed"
PlanArchived PlanStatus = "archived"
)
// PhaseStatus represents the state of a phase within a plan.
type PhaseStatus string
const (
PhasePending PhaseStatus = "pending"
PhaseInProgress PhaseStatus = "in_progress"
PhaseCompleted PhaseStatus = "completed"
PhaseBlocked PhaseStatus = "blocked"
PhaseSkipped PhaseStatus = "skipped"
)
// Plan represents an agent plan from the PHP API.
type Plan struct {
Slug string `json:"slug"`
Title string `json:"title"`
Description string `json:"description,omitempty"`
Status PlanStatus `json:"status"`
CurrentPhase int `json:"current_phase,omitempty"`
Progress Progress `json:"progress,omitempty"`
Phases []Phase `json:"phases,omitempty"`
Metadata any `json:"metadata,omitempty"`
CreatedAt string `json:"created_at,omitempty"`
UpdatedAt string `json:"updated_at,omitempty"`
}
// Phase represents a phase within a plan.
type Phase struct {
ID int `json:"id,omitempty"`
Order int `json:"order"`
Name string `json:"name"`
Description string `json:"description,omitempty"`
Status PhaseStatus `json:"status"`
Tasks []PhaseTask `json:"tasks,omitempty"`
TaskProgress TaskProgress `json:"task_progress,omitempty"`
RemainingTasks []string `json:"remaining_tasks,omitempty"`
Dependencies []int `json:"dependencies,omitempty"`
DependencyBlockers []string `json:"dependency_blockers,omitempty"`
CanStart bool `json:"can_start,omitempty"`
Checkpoints []any `json:"checkpoints,omitempty"`
StartedAt string `json:"started_at,omitempty"`
CompletedAt string `json:"completed_at,omitempty"`
Metadata any `json:"metadata,omitempty"`
}
// PhaseTask represents a task within a phase. Tasks are stored as a JSON array
// in the phase and may be simple strings or objects with status/notes.
type PhaseTask struct {
Name string `json:"name"`
Status string `json:"status,omitempty"`
Notes string `json:"notes,omitempty"`
}
// UnmarshalJSON handles the fact that tasks can be either strings or objects.
func (t *PhaseTask) UnmarshalJSON(data []byte) error {
// Try string first
var s string
if err := json.Unmarshal(data, &s); err == nil {
t.Name = s
t.Status = "pending"
return nil
}
// Try object
type taskAlias PhaseTask
var obj taskAlias
if err := json.Unmarshal(data, &obj); err != nil {
return err
}
*t = PhaseTask(obj)
return nil
}
// Progress represents plan progress metrics.
type Progress struct {
Total int `json:"total"`
Completed int `json:"completed"`
InProgress int `json:"in_progress"`
Pending int `json:"pending"`
Percentage int `json:"percentage"`
}
// TaskProgress represents task-level progress within a phase.
type TaskProgress struct {
Total int `json:"total"`
Completed int `json:"completed"`
Pending int `json:"pending"`
Percentage int `json:"percentage"`
}
// ListPlanOptions specifies filters for listing plans.
type ListPlanOptions struct {
Status PlanStatus `json:"status,omitempty"`
IncludeArchived bool `json:"include_archived,omitempty"`
}
// CreatePlanRequest is the payload for creating a new plan.
type CreatePlanRequest struct {
Title string `json:"title"`
Slug string `json:"slug,omitempty"`
Description string `json:"description,omitempty"`
Context map[string]any `json:"context,omitempty"`
Phases []CreatePhaseInput `json:"phases,omitempty"`
}
// CreatePhaseInput is a phase definition for plan creation.
type CreatePhaseInput struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Tasks []string `json:"tasks,omitempty"`
}
// planListResponse wraps the list endpoint response.
type planListResponse struct {
Plans []Plan `json:"plans"`
Total int `json:"total"`
}
// planCreateResponse wraps the create endpoint response.
type planCreateResponse struct {
Slug string `json:"slug"`
Title string `json:"title"`
Status string `json:"status"`
Phases int `json:"phases"`
}
// planUpdateResponse wraps the update endpoint response.
type planUpdateResponse struct {
Slug string `json:"slug"`
Status string `json:"status"`
}
// planArchiveResponse wraps the archive endpoint response.
type planArchiveResponse struct {
Slug string `json:"slug"`
Status string `json:"status"`
ArchivedAt string `json:"archived_at,omitempty"`
}
// ListPlans retrieves plans matching the given options.
func (c *Client) ListPlans(ctx context.Context, opts ListPlanOptions) ([]Plan, error) {
const op = "agentic.Client.ListPlans"
params := url.Values{}
if opts.Status != "" {
params.Set("status", string(opts.Status))
}
if opts.IncludeArchived {
params.Set("include_archived", "1")
}
endpoint := c.BaseURL + "/v1/plans"
if len(params) > 0 {
endpoint += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
var result planListResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return result.Plans, nil
}
// GetPlan retrieves a plan by slug (returns full detail with phases).
func (c *Client) GetPlan(ctx context.Context, slug string) (*Plan, error) {
const op = "agentic.Client.GetPlan"
if slug == "" {
return nil, log.E(op, "plan slug is required", nil)
}
endpoint := fmt.Sprintf("%s/v1/plans/%s", c.BaseURL, url.PathEscape(slug))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
var plan Plan
if err := json.NewDecoder(resp.Body).Decode(&plan); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return &plan, nil
}
// CreatePlan creates a new plan with optional phases.
func (c *Client) CreatePlan(ctx context.Context, req CreatePlanRequest) (*planCreateResponse, error) {
const op = "agentic.Client.CreatePlan"
if req.Title == "" {
return nil, log.E(op, "title is required", nil)
}
data, err := json.Marshal(req)
if err != nil {
return nil, log.E(op, "failed to marshal request", err)
}
endpoint := c.BaseURL + "/v1/plans"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
c.setHeaders(httpReq)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
var result planCreateResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return &result, nil
}
// UpdatePlanStatus changes a plan's status.
func (c *Client) UpdatePlanStatus(ctx context.Context, slug string, status PlanStatus) error {
const op = "agentic.Client.UpdatePlanStatus"
if slug == "" {
return log.E(op, "plan slug is required", nil)
}
data, err := json.Marshal(map[string]string{"status": string(status)})
if err != nil {
return log.E(op, "failed to marshal request", err)
}
endpoint := fmt.Sprintf("%s/v1/plans/%s", c.BaseURL, url.PathEscape(slug))
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(data))
if err != nil {
return log.E(op, "failed to create request", err)
}
c.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
return c.checkResponse(resp)
}
// ArchivePlan archives a plan with an optional reason.
func (c *Client) ArchivePlan(ctx context.Context, slug string, reason string) error {
const op = "agentic.Client.ArchivePlan"
if slug == "" {
return log.E(op, "plan slug is required", nil)
}
endpoint := fmt.Sprintf("%s/v1/plans/%s", c.BaseURL, url.PathEscape(slug))
var body *bytes.Reader
if reason != "" {
data, _ := json.Marshal(map[string]string{"reason": reason})
body = bytes.NewReader(data)
}
var reqBody *bytes.Reader
if body != nil {
reqBody = body
}
var httpReq *http.Request
var err error
if reqBody != nil {
httpReq, err = http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, reqBody)
if err != nil {
return log.E(op, "failed to create request", err)
}
httpReq.Header.Set("Content-Type", "application/json")
} else {
httpReq, err = http.NewRequestWithContext(ctx, http.MethodDelete, endpoint, nil)
if err != nil {
return log.E(op, "failed to create request", err)
}
}
c.setHeaders(httpReq)
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
return c.checkResponse(resp)
}
// GetPhase retrieves a specific phase within a plan.
func (c *Client) GetPhase(ctx context.Context, planSlug string, phase string) (*Phase, error) {
const op = "agentic.Client.GetPhase"
if planSlug == "" || phase == "" {
return nil, log.E(op, "plan slug and phase identifier are required", nil)
}
endpoint := fmt.Sprintf("%s/v1/plans/%s/phases/%s",
c.BaseURL, url.PathEscape(planSlug), url.PathEscape(phase))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
var result Phase
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return &result, nil
}
// UpdatePhaseStatus changes a phase's status.
func (c *Client) UpdatePhaseStatus(ctx context.Context, planSlug, phase string, status PhaseStatus, notes string) error {
const op = "agentic.Client.UpdatePhaseStatus"
if planSlug == "" || phase == "" {
return log.E(op, "plan slug and phase identifier are required", nil)
}
payload := map[string]string{"status": string(status)}
if notes != "" {
payload["notes"] = notes
}
data, err := json.Marshal(payload)
if err != nil {
return log.E(op, "failed to marshal request", err)
}
endpoint := fmt.Sprintf("%s/v1/plans/%s/phases/%s",
c.BaseURL, url.PathEscape(planSlug), url.PathEscape(phase))
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(data))
if err != nil {
return log.E(op, "failed to create request", err)
}
c.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
return c.checkResponse(resp)
}
// AddCheckpoint adds a checkpoint note to a phase.
func (c *Client) AddCheckpoint(ctx context.Context, planSlug, phase, note string, checkpointCtx map[string]any) error {
const op = "agentic.Client.AddCheckpoint"
if planSlug == "" || phase == "" || note == "" {
return log.E(op, "plan slug, phase, and note are required", nil)
}
payload := map[string]any{"note": note}
if len(checkpointCtx) > 0 {
payload["context"] = checkpointCtx
}
data, err := json.Marshal(payload)
if err != nil {
return log.E(op, "failed to marshal request", err)
}
endpoint := fmt.Sprintf("%s/v1/plans/%s/phases/%s/checkpoint",
c.BaseURL, url.PathEscape(planSlug), url.PathEscape(phase))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
if err != nil {
return log.E(op, "failed to create request", err)
}
c.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
return c.checkResponse(resp)
}
// UpdateTaskStatus updates a task within a phase.
func (c *Client) UpdateTaskStatus(ctx context.Context, planSlug, phase string, taskIdx int, status string, notes string) error {
const op = "agentic.Client.UpdateTaskStatus"
if planSlug == "" || phase == "" {
return log.E(op, "plan slug and phase are required", nil)
}
payload := map[string]any{}
if status != "" {
payload["status"] = status
}
if notes != "" {
payload["notes"] = notes
}
data, err := json.Marshal(payload)
if err != nil {
return log.E(op, "failed to marshal request", err)
}
endpoint := fmt.Sprintf("%s/v1/plans/%s/phases/%s/tasks/%d",
c.BaseURL, url.PathEscape(planSlug), url.PathEscape(phase), taskIdx)
req, err := http.NewRequestWithContext(ctx, http.MethodPatch, endpoint, bytes.NewReader(data))
if err != nil {
return log.E(op, "failed to create request", err)
}
c.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
return c.checkResponse(resp)
}
// ToggleTask toggles a task between pending and completed.
func (c *Client) ToggleTask(ctx context.Context, planSlug, phase string, taskIdx int) error {
const op = "agentic.Client.ToggleTask"
if planSlug == "" || phase == "" {
return log.E(op, "plan slug and phase are required", nil)
}
endpoint := fmt.Sprintf("%s/v1/plans/%s/phases/%s/tasks/%d/toggle",
c.BaseURL, url.PathEscape(planSlug), url.PathEscape(phase), taskIdx)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, nil)
if err != nil {
return log.E(op, "failed to create request", err)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
return c.checkResponse(resp)
}

287
sessions.go Normal file
View file

@ -0,0 +1,287 @@
package agentic
import (
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"net/url"
"strconv"
"forge.lthn.ai/core/go/pkg/log"
)
// SessionStatus represents the state of a session.
type SessionStatus string
const (
SessionActive SessionStatus = "active"
SessionPaused SessionStatus = "paused"
SessionCompleted SessionStatus = "completed"
SessionFailed SessionStatus = "failed"
)
// Session represents an agent session from the PHP API.
type Session struct {
SessionID string `json:"session_id"`
AgentType string `json:"agent_type"`
Status SessionStatus `json:"status"`
PlanSlug string `json:"plan_slug,omitempty"`
Plan string `json:"plan,omitempty"`
Duration string `json:"duration,omitempty"`
StartedAt string `json:"started_at,omitempty"`
LastActiveAt string `json:"last_active_at,omitempty"`
EndedAt string `json:"ended_at,omitempty"`
ActionCount int `json:"action_count,omitempty"`
ArtifactCount int `json:"artifact_count,omitempty"`
ContextSummary map[string]any `json:"context_summary,omitempty"`
HandoffNotes string `json:"handoff_notes,omitempty"`
ContinuedFrom string `json:"continued_from,omitempty"`
}
// StartSessionRequest is the payload for starting a new session.
type StartSessionRequest struct {
AgentType string `json:"agent_type"`
PlanSlug string `json:"plan_slug,omitempty"`
Context map[string]any `json:"context,omitempty"`
}
// EndSessionRequest is the payload for ending a session.
type EndSessionRequest struct {
Status string `json:"status"`
Summary string `json:"summary,omitempty"`
}
// ListSessionOptions specifies filters for listing sessions.
type ListSessionOptions struct {
Status SessionStatus `json:"status,omitempty"`
PlanSlug string `json:"plan_slug,omitempty"`
Limit int `json:"limit,omitempty"`
}
// sessionListResponse wraps the list endpoint response.
type sessionListResponse struct {
Sessions []Session `json:"sessions"`
Total int `json:"total"`
}
// sessionStartResponse wraps the session create endpoint response.
type sessionStartResponse struct {
SessionID string `json:"session_id"`
AgentType string `json:"agent_type"`
Plan string `json:"plan,omitempty"`
Status string `json:"status"`
}
// sessionEndResponse wraps the session end endpoint response.
type sessionEndResponse struct {
SessionID string `json:"session_id"`
Status string `json:"status"`
Duration string `json:"duration,omitempty"`
}
// sessionContinueResponse wraps the session continue endpoint response.
type sessionContinueResponse struct {
SessionID string `json:"session_id"`
AgentType string `json:"agent_type"`
Plan string `json:"plan,omitempty"`
Status string `json:"status"`
ContinuedFrom string `json:"continued_from,omitempty"`
}
// ListSessions retrieves sessions matching the given options.
func (c *Client) ListSessions(ctx context.Context, opts ListSessionOptions) ([]Session, error) {
const op = "agentic.Client.ListSessions"
params := url.Values{}
if opts.Status != "" {
params.Set("status", string(opts.Status))
}
if opts.PlanSlug != "" {
params.Set("plan_slug", opts.PlanSlug)
}
if opts.Limit > 0 {
params.Set("limit", strconv.Itoa(opts.Limit))
}
endpoint := c.BaseURL + "/v1/sessions"
if len(params) > 0 {
endpoint += "?" + params.Encode()
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
var result sessionListResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return result.Sessions, nil
}
// GetSession retrieves a session by ID.
func (c *Client) GetSession(ctx context.Context, sessionID string) (*Session, error) {
const op = "agentic.Client.GetSession"
if sessionID == "" {
return nil, log.E(op, "session ID is required", nil)
}
endpoint := fmt.Sprintf("%s/v1/sessions/%s", c.BaseURL, url.PathEscape(sessionID))
req, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil)
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
c.setHeaders(req)
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
var session Session
if err := json.NewDecoder(resp.Body).Decode(&session); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return &session, nil
}
// StartSession starts a new agent session.
func (c *Client) StartSession(ctx context.Context, req StartSessionRequest) (*sessionStartResponse, error) {
const op = "agentic.Client.StartSession"
if req.AgentType == "" {
return nil, log.E(op, "agent_type is required", nil)
}
data, err := json.Marshal(req)
if err != nil {
return nil, log.E(op, "failed to marshal request", err)
}
endpoint := c.BaseURL + "/v1/sessions"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
c.setHeaders(httpReq)
httpReq.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(httpReq)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
var result sessionStartResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return &result, nil
}
// EndSession ends a session with a final status and optional summary.
func (c *Client) EndSession(ctx context.Context, sessionID string, status string, summary string) error {
const op = "agentic.Client.EndSession"
if sessionID == "" {
return log.E(op, "session ID is required", nil)
}
if status == "" {
return log.E(op, "status is required", nil)
}
payload := EndSessionRequest{Status: status, Summary: summary}
data, err := json.Marshal(payload)
if err != nil {
return log.E(op, "failed to marshal request", err)
}
endpoint := fmt.Sprintf("%s/v1/sessions/%s/end", c.BaseURL, url.PathEscape(sessionID))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
if err != nil {
return log.E(op, "failed to create request", err)
}
c.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
return c.checkResponse(resp)
}
// ContinueSession creates a new session continuing from a previous one (multi-agent handoff).
func (c *Client) ContinueSession(ctx context.Context, previousSessionID, agentType string) (*sessionContinueResponse, error) {
const op = "agentic.Client.ContinueSession"
if previousSessionID == "" {
return nil, log.E(op, "previous session ID is required", nil)
}
if agentType == "" {
return nil, log.E(op, "agent_type is required", nil)
}
data, err := json.Marshal(map[string]string{"agent_type": agentType})
if err != nil {
return nil, log.E(op, "failed to marshal request", err)
}
endpoint := fmt.Sprintf("%s/v1/sessions/%s/continue", c.BaseURL, url.PathEscape(previousSessionID))
req, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(data))
if err != nil {
return nil, log.E(op, "failed to create request", err)
}
c.setHeaders(req)
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTPClient.Do(req)
if err != nil {
return nil, log.E(op, "request failed", err)
}
defer func() { _ = resp.Body.Close() }()
if err := c.checkResponse(resp); err != nil {
return nil, log.E(op, "API error", err)
}
var result sessionContinueResponse
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, log.E(op, "failed to decode response", err)
}
return &result, nil
}