From ecd8d17a8790e4df07c54d80dbede65438235ef9 Mon Sep 17 00:00:00 2001 From: Snider Date: Wed, 4 Mar 2026 13:58:55 +0000 Subject: [PATCH] feat: add plan/session/phase API client and PlanDispatcher 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 --- client.go | 2 +- client_test.go | 2 +- config.go | 6 +- plan_dispatcher.go | 197 +++++++++++++++++ plans.go | 525 +++++++++++++++++++++++++++++++++++++++++++++ sessions.go | 287 +++++++++++++++++++++++++ 6 files changed, 1015 insertions(+), 4 deletions(-) create mode 100644 plan_dispatcher.go create mode 100644 plans.go create mode 100644 sessions.go diff --git a/client.go b/client.go index 9cec2f2..9c38f3c 100644 --- a/client.go +++ b/client.go @@ -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 { diff --git a/client_test.go b/client_test.go index 587e0dc..df97989 100644 --- a/client_test.go +++ b/client_test.go @@ -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() diff --git a/config.go b/config.go index d063757..390937c 100644 --- a/config.go +++ b/config.go @@ -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. diff --git a/plan_dispatcher.go b/plan_dispatcher.go new file mode 100644 index 0000000..fd76e11 --- /dev/null +++ b/plan_dispatcher.go @@ -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 +} diff --git a/plans.go b/plans.go new file mode 100644 index 0000000..a236a03 --- /dev/null +++ b/plans.go @@ -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) +} diff --git a/sessions.go b/sessions.go new file mode 100644 index 0000000..7e74caa --- /dev/null +++ b/sessions.go @@ -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 +}