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 <virgil@lethean.io>
This commit is contained in:
parent
deb7021b93
commit
ecd8d17a87
6 changed files with 1015 additions and 4 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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
197
plan_dispatcher.go
Normal 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
525
plans.go
Normal 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
287
sessions.go
Normal 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
|
||||
}
|
||||
Reference in a new issue