Replaced fmt, strings, sort, os, io, sync, encoding/json, path/filepath, errors, log, reflect with core.Sprintf, core.E, core.Contains, core.Trim, core.Split, core.Join, core.JoinPath, slices.Sort, c.Fs(), c.Lock(), core.JSONMarshal, core.ReadAll and other CoreGO v0.8.0 primitives. Framework boundary exceptions preserved where stdlib types are required by external interfaces (Gin, net/http, CGo, Wails, bubbletea). Co-Authored-By: Virgil <virgil@lethean.io>
404 lines
12 KiB
Go
404 lines
12 KiB
Go
// SPDX-License-Identifier: EUPL-1.2
|
|
|
|
package agentic
|
|
|
|
import (
|
|
"context"
|
|
"time"
|
|
|
|
core "dappco.re/go/core"
|
|
coremcp "dappco.re/go/mcp/pkg/mcp"
|
|
"github.com/modelcontextprotocol/go-sdk/mcp"
|
|
)
|
|
|
|
// state := agentic.WorkspaceState{Key: "pattern", Value: "observer", Type: "general", Description: "Shared across sessions"}
|
|
type WorkspaceState struct {
|
|
AgentPlanID int `json:"agent_plan_id,omitempty"`
|
|
Key string `json:"key"`
|
|
Value any `json:"value"`
|
|
Type string `json:"type,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Category string `json:"category,omitempty"`
|
|
UpdatedAt string `json:"updated_at,omitempty"`
|
|
}
|
|
|
|
// state := agentic.PlanState{Key: "pattern", Value: "observer", Type: "general"}
|
|
type PlanState = WorkspaceState
|
|
|
|
// input := agentic.StateSetInput{PlanSlug: "ax-follow-up", Key: "pattern", Value: "observer", Type: "general", Description: "Shared across sessions"}
|
|
type StateSetInput struct {
|
|
PlanSlug string `json:"plan_slug"`
|
|
Key string `json:"key"`
|
|
Value any `json:"value"`
|
|
Type string `json:"type,omitempty"`
|
|
Description string `json:"description,omitempty"`
|
|
Category string `json:"category,omitempty"`
|
|
}
|
|
|
|
// input := agentic.StateGetInput{PlanSlug: "ax-follow-up", Key: "pattern"}
|
|
type StateGetInput struct {
|
|
PlanSlug string `json:"plan_slug"`
|
|
Key string `json:"key"`
|
|
}
|
|
|
|
// input := agentic.StateListInput{PlanSlug: "ax-follow-up", Type: "general"}
|
|
type StateListInput struct {
|
|
PlanSlug string `json:"plan_slug"`
|
|
Type string `json:"type,omitempty"`
|
|
Category string `json:"category,omitempty"`
|
|
}
|
|
|
|
// input := agentic.StateDeleteInput{PlanSlug: "ax-follow-up", Key: "pattern"}
|
|
type StateDeleteInput struct {
|
|
PlanSlug string `json:"plan_slug"`
|
|
Key string `json:"key"`
|
|
}
|
|
|
|
// out := agentic.StateOutput{Success: true, State: agentic.WorkspaceState{Key: "pattern"}}
|
|
type StateOutput struct {
|
|
Success bool `json:"success"`
|
|
State WorkspaceState `json:"state"`
|
|
}
|
|
|
|
// out := agentic.StateListOutput{Success: true, Total: 1, States: []agentic.WorkspaceState{{Key: "pattern"}}}
|
|
type StateListOutput struct {
|
|
Success bool `json:"success"`
|
|
Total int `json:"total"`
|
|
States []WorkspaceState `json:"states"`
|
|
}
|
|
|
|
// out := agentic.StateDeleteOutput{Success: true, Deleted: agentic.WorkspaceState{Key: "pattern"}}
|
|
type StateDeleteOutput struct {
|
|
Success bool `json:"success"`
|
|
Deleted WorkspaceState `json:"deleted"`
|
|
}
|
|
|
|
// result := c.Action("state.set").Run(ctx, core.NewOptions(
|
|
//
|
|
// core.Option{Key: "plan_slug", Value: "ax-follow-up"},
|
|
// core.Option{Key: "key", Value: "pattern"},
|
|
// core.Option{Key: "value", Value: "observer"},
|
|
//
|
|
// ))
|
|
func (s *PrepSubsystem) handleStateSet(ctx context.Context, options core.Options) core.Result {
|
|
_, output, err := s.stateSet(ctx, nil, StateSetInput{
|
|
PlanSlug: optionStringValue(options, "plan_slug", "plan"),
|
|
Key: optionStringValue(options, "key"),
|
|
Value: optionAnyValue(options, "value"),
|
|
Type: optionStringValue(options, "type"),
|
|
Description: optionStringValue(options, "description"),
|
|
Category: optionStringValue(options, "category"),
|
|
})
|
|
if err != nil {
|
|
return core.Result{Value: err, OK: false}
|
|
}
|
|
return core.Result{Value: output, OK: true}
|
|
}
|
|
|
|
// result := c.Action("state.get").Run(ctx, core.NewOptions(
|
|
//
|
|
// core.Option{Key: "plan_slug", Value: "ax-follow-up"},
|
|
// core.Option{Key: "key", Value: "pattern"},
|
|
//
|
|
// ))
|
|
func (s *PrepSubsystem) handleStateGet(ctx context.Context, options core.Options) core.Result {
|
|
_, output, err := s.stateGet(ctx, nil, StateGetInput{
|
|
PlanSlug: optionStringValue(options, "plan_slug", "plan"),
|
|
Key: optionStringValue(options, "key"),
|
|
})
|
|
if err != nil {
|
|
return core.Result{Value: err, OK: false}
|
|
}
|
|
return core.Result{Value: output, OK: true}
|
|
}
|
|
|
|
// result := c.Action("state.list").Run(ctx, core.NewOptions(core.Option{Key: "plan_slug", Value: "ax-follow-up"}))
|
|
func (s *PrepSubsystem) handleStateList(ctx context.Context, options core.Options) core.Result {
|
|
_, output, err := s.stateList(ctx, nil, StateListInput{
|
|
PlanSlug: optionStringValue(options, "plan_slug", "plan"),
|
|
Type: optionStringValue(options, "type"),
|
|
Category: optionStringValue(options, "category"),
|
|
})
|
|
if err != nil {
|
|
return core.Result{Value: err, OK: false}
|
|
}
|
|
return core.Result{Value: output, OK: true}
|
|
}
|
|
|
|
// result := c.Action("state.delete").Run(ctx, core.NewOptions(
|
|
//
|
|
// core.Option{Key: "plan_slug", Value: "ax-follow-up"},
|
|
// core.Option{Key: "key", Value: "pattern"},
|
|
//
|
|
// ))
|
|
func (s *PrepSubsystem) handleStateDelete(ctx context.Context, options core.Options) core.Result {
|
|
_, output, err := s.stateDelete(ctx, nil, StateDeleteInput{
|
|
PlanSlug: optionStringValue(options, "plan_slug", "plan"),
|
|
Key: optionStringValue(options, "key"),
|
|
})
|
|
if err != nil {
|
|
return core.Result{Value: err, OK: false}
|
|
}
|
|
return core.Result{Value: output, OK: true}
|
|
}
|
|
|
|
func (s *PrepSubsystem) registerStateTools(svc *coremcp.Service) {
|
|
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
|
|
Name: "state_set",
|
|
Description: "Set a typed workspace state value for a plan so later sessions can reuse shared context.",
|
|
}, s.stateSet)
|
|
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
|
|
Name: "agentic_state_set",
|
|
Description: "Set a typed workspace state value for a plan so later sessions can reuse shared context.",
|
|
}, s.stateSet)
|
|
|
|
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
|
|
Name: "state_get",
|
|
Description: "Get a workspace state value for a plan by key.",
|
|
}, s.stateGet)
|
|
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
|
|
Name: "agentic_state_get",
|
|
Description: "Get a workspace state value for a plan by key.",
|
|
}, s.stateGet)
|
|
|
|
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
|
|
Name: "state_list",
|
|
Description: "List all stored workspace state values for a plan, with optional type or category filtering.",
|
|
}, s.stateList)
|
|
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
|
|
Name: "agentic_state_list",
|
|
Description: "List all stored workspace state values for a plan, with optional type or category filtering.",
|
|
}, s.stateList)
|
|
|
|
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
|
|
Name: "state_delete",
|
|
Description: "Delete a stored workspace state value for a plan by key.",
|
|
}, s.stateDelete)
|
|
coremcp.AddToolRecorded(svc, svc.Server(), "agentic", &mcp.Tool{
|
|
Name: "agentic_state_delete",
|
|
Description: "Delete a stored workspace state value for a plan by key.",
|
|
}, s.stateDelete)
|
|
}
|
|
|
|
func (s *PrepSubsystem) stateSet(_ context.Context, _ *mcp.CallToolRequest, input StateSetInput) (*mcp.CallToolResult, StateOutput, error) {
|
|
if input.PlanSlug == "" {
|
|
return nil, StateOutput{}, core.E("stateSet", "plan_slug is required", nil)
|
|
}
|
|
if input.Key == "" {
|
|
return nil, StateOutput{}, core.E("stateSet", "key is required", nil)
|
|
}
|
|
if input.Value == nil {
|
|
return nil, StateOutput{}, core.E("stateSet", "value is required", nil)
|
|
}
|
|
|
|
states, err := readPlanStates(input.PlanSlug)
|
|
if err != nil {
|
|
return nil, StateOutput{}, err
|
|
}
|
|
|
|
now := time.Now().Format(time.RFC3339)
|
|
stateType := core.Trim(input.Type)
|
|
if stateType == "" {
|
|
stateType = core.Trim(input.Category)
|
|
}
|
|
if stateType == "" {
|
|
stateType = "general"
|
|
}
|
|
state := WorkspaceState{
|
|
Key: input.Key,
|
|
Value: input.Value,
|
|
Type: stateType,
|
|
Description: core.Trim(input.Description),
|
|
Category: stateType,
|
|
UpdatedAt: now,
|
|
}
|
|
|
|
found := false
|
|
for i := range states {
|
|
if states[i].Key == input.Key {
|
|
states[i] = state
|
|
found = true
|
|
break
|
|
}
|
|
}
|
|
if !found {
|
|
states = append(states, state)
|
|
}
|
|
|
|
if err := writePlanStates(input.PlanSlug, states); err != nil {
|
|
return nil, StateOutput{}, err
|
|
}
|
|
|
|
return nil, StateOutput{
|
|
Success: true,
|
|
State: state,
|
|
}, nil
|
|
}
|
|
|
|
func (s *PrepSubsystem) stateGet(_ context.Context, _ *mcp.CallToolRequest, input StateGetInput) (*mcp.CallToolResult, StateOutput, error) {
|
|
if input.PlanSlug == "" {
|
|
return nil, StateOutput{}, core.E("stateGet", "plan_slug is required", nil)
|
|
}
|
|
if input.Key == "" {
|
|
return nil, StateOutput{}, core.E("stateGet", "key is required", nil)
|
|
}
|
|
|
|
states, err := readPlanStates(input.PlanSlug)
|
|
if err != nil {
|
|
return nil, StateOutput{}, err
|
|
}
|
|
|
|
for _, state := range states {
|
|
if state.Key == input.Key {
|
|
state = normaliseWorkspaceState(state)
|
|
return nil, StateOutput{
|
|
Success: true,
|
|
State: state,
|
|
}, nil
|
|
}
|
|
}
|
|
|
|
return nil, StateOutput{}, core.E("stateGet", core.Concat("state not found: ", input.Key), nil)
|
|
}
|
|
|
|
func (s *PrepSubsystem) stateList(_ context.Context, _ *mcp.CallToolRequest, input StateListInput) (*mcp.CallToolResult, StateListOutput, error) {
|
|
if input.PlanSlug == "" {
|
|
return nil, StateListOutput{}, core.E("stateList", "plan_slug is required", nil)
|
|
}
|
|
|
|
states, err := readPlanStates(input.PlanSlug)
|
|
if err != nil {
|
|
return nil, StateListOutput{}, err
|
|
}
|
|
|
|
filtered := make([]WorkspaceState, 0, len(states))
|
|
for _, state := range states {
|
|
state = normaliseWorkspaceState(state)
|
|
if input.Type != "" && state.Type != input.Type {
|
|
continue
|
|
}
|
|
if input.Category != "" && state.Category != input.Category {
|
|
continue
|
|
}
|
|
filtered = append(filtered, state)
|
|
}
|
|
|
|
return nil, StateListOutput{
|
|
Success: true,
|
|
Total: len(filtered),
|
|
States: filtered,
|
|
}, nil
|
|
}
|
|
|
|
func (s *PrepSubsystem) stateDelete(_ context.Context, _ *mcp.CallToolRequest, input StateDeleteInput) (*mcp.CallToolResult, StateDeleteOutput, error) {
|
|
if input.PlanSlug == "" {
|
|
return nil, StateDeleteOutput{}, core.E("stateDelete", "plan_slug is required", nil)
|
|
}
|
|
if input.Key == "" {
|
|
return nil, StateDeleteOutput{}, core.E("stateDelete", "key is required", nil)
|
|
}
|
|
|
|
states, err := readPlanStates(input.PlanSlug)
|
|
if err != nil {
|
|
return nil, StateDeleteOutput{}, err
|
|
}
|
|
|
|
filtered := make([]WorkspaceState, 0, len(states))
|
|
deleted := WorkspaceState{}
|
|
found := false
|
|
for _, state := range states {
|
|
if state.Key == input.Key {
|
|
deleted = normaliseWorkspaceState(state)
|
|
found = true
|
|
continue
|
|
}
|
|
filtered = append(filtered, state)
|
|
}
|
|
|
|
if !found {
|
|
return nil, StateDeleteOutput{}, core.E("stateDelete", core.Concat("state not found: ", input.Key), nil)
|
|
}
|
|
|
|
path := statePath(input.PlanSlug)
|
|
if len(filtered) == 0 {
|
|
if deleteResult := fs.Delete(path); !deleteResult.OK {
|
|
err, _ := deleteResult.Value.(error)
|
|
return nil, StateDeleteOutput{}, core.E("stateDelete", "failed to delete empty state file", err)
|
|
}
|
|
} else if err := writePlanStates(input.PlanSlug, filtered); err != nil {
|
|
return nil, StateDeleteOutput{}, err
|
|
}
|
|
|
|
return nil, StateDeleteOutput{
|
|
Success: true,
|
|
Deleted: deleted,
|
|
}, nil
|
|
}
|
|
|
|
func stateRoot() string {
|
|
return core.JoinPath(CoreRoot(), "state")
|
|
}
|
|
|
|
func statePath(planSlug string) string {
|
|
return core.JoinPath(stateRoot(), core.Concat(pathKey(planSlug), ".json"))
|
|
}
|
|
|
|
func readPlanStates(planSlug string) ([]WorkspaceState, error) {
|
|
result := fs.Read(statePath(planSlug))
|
|
if !result.OK {
|
|
err, _ := result.Value.(error)
|
|
if err == nil {
|
|
return []WorkspaceState{}, nil
|
|
}
|
|
if core.Contains(err.Error(), "no such file") {
|
|
return []WorkspaceState{}, nil
|
|
}
|
|
return nil, core.E("readPlanStates", "failed to read state file", err)
|
|
}
|
|
|
|
content := core.Trim(result.Value.(string))
|
|
if content == "" {
|
|
return []WorkspaceState{}, nil
|
|
}
|
|
|
|
var states []WorkspaceState
|
|
if parseResult := core.JSONUnmarshalString(content, &states); !parseResult.OK {
|
|
err, _ := parseResult.Value.(error)
|
|
return nil, core.E("readPlanStates", "failed to parse state file", err)
|
|
}
|
|
|
|
return states, nil
|
|
}
|
|
|
|
func writePlanStates(planSlug string, states []WorkspaceState) error {
|
|
if ensureDirResult := fs.EnsureDir(stateRoot()); !ensureDirResult.OK {
|
|
err, _ := ensureDirResult.Value.(error)
|
|
return core.E("writePlanStates", "failed to create state directory", err)
|
|
}
|
|
|
|
if writeResult := fs.WriteAtomic(statePath(planSlug), core.JSONMarshalString(states)); !writeResult.OK {
|
|
err, _ := writeResult.Value.(error)
|
|
return core.E("writePlanStates", "failed to write state file", err)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func normaliseWorkspaceState(state WorkspaceState) WorkspaceState {
|
|
state.Type = core.Trim(state.Type)
|
|
state.Category = core.Trim(state.Category)
|
|
if state.Type == "" {
|
|
state.Type = state.Category
|
|
}
|
|
if state.Category == "" {
|
|
state.Category = state.Type
|
|
}
|
|
if state.Type == "" {
|
|
state.Type = "general"
|
|
}
|
|
if state.Category == "" {
|
|
state.Category = state.Type
|
|
}
|
|
state.Description = core.Trim(state.Description)
|
|
return state
|
|
}
|