Skip to content

Commit 97bc956

Browse files
authored
fix: persist HITL tasks across actor suspension (#2567)
## Summary - persist the actor-local A2A task store in `tasks.db` under DurableDir - resume `INPUT_REQUIRED` and `AUTH_REQUIRED` tasks after actor quiescence while keeping failed replies retryable - add focused persistence/continuation tests and an `ask_user` suspend-resume E2E Fixes solo-io/kagent-enterprise#2510 ## Testing - `go test ./adk/... ./core/v2/...` - `go test ./adk/pkg/taskstore ./adk/pkg/app ./core/v2/a2agateway` - `go test ./core/test/e2e -run '^$'` (compile check; live E2E requires rebuilt cluster images) --------- Signed-off-by: Eitan Yarmush <eitan.yarmush@solo.io>
1 parent 76cde41 commit 97bc956

9 files changed

Lines changed: 690 additions & 36 deletions

File tree

go/adk/cmd/main.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -241,6 +241,7 @@ func main() {
241241
ShutdownTimeout: 5 * time.Second,
242242
Logger: logger,
243243
Agent: runnerConfig.Agent,
244+
SessionDBURL: agentConfig.SessionDBURL,
244245
}, executor)
245246
if err != nil {
246247
logger.Error(err, "Failed to create app")

go/adk/pkg/app/app.go

Lines changed: 16 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
"github.com/go-logr/zapr"
1616
"github.com/kagent-dev/kagent/go/adk/pkg/a2a"
1717
"github.com/kagent-dev/kagent/go/adk/pkg/a2a/server"
18+
localtaskstore "github.com/kagent-dev/kagent/go/adk/pkg/taskstore"
1819
"go.uber.org/zap"
1920
"go.uber.org/zap/zapcore"
2021
adkagent "google.golang.org/adk/v2/agent"
@@ -55,6 +56,10 @@ type AppConfig struct {
5556
// Agent is the ADK agent used to enrich the agent card with skills via
5657
// adka2a.BuildAgentSkills. Optional; when nil, the card is used as-is.
5758
Agent adkagent.Agent
59+
60+
// SessionDBURL locates actor-local durable storage. When set, A2A tasks are
61+
// persisted beside the ADK session database so HITL tasks survive suspension.
62+
SessionDBURL string
5863
}
5964

6065
// KAgentApp wires an AgentExecutor with kagent's A2A server.
@@ -99,7 +104,17 @@ func New(cfg AppConfig, executor a2asrv.AgentExecutor) (*KAgentApp, error) {
99104
log := cfg.Logger
100105

101106
app := &KAgentApp{logger: log}
102-
tasks := a2ataskstore.NewInMemory(&a2ataskstore.InMemoryStoreConfig{Authenticator: a2asrv.NewTaskStoreAuthenticator()})
107+
authenticator := a2asrv.NewTaskStoreAuthenticator()
108+
var tasks a2ataskstore.Store
109+
if cfg.SessionDBURL == "" {
110+
tasks = a2ataskstore.NewInMemory(&a2ataskstore.InMemoryStoreConfig{Authenticator: authenticator})
111+
} else {
112+
var err error
113+
tasks, err = localtaskstore.New(cfg.SessionDBURL, authenticator)
114+
if err != nil {
115+
return nil, fmt.Errorf("open local task store: %w", err)
116+
}
117+
}
103118
handlerOpts := []a2asrv.RequestHandlerOption{a2asrv.WithTaskStore(tasks)}
104119

105120
// The private runtime receives a gateway-assigned ID for a new task. Seed it

go/adk/pkg/taskstore/local.go

Lines changed: 260 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,260 @@
1+
package taskstore
2+
3+
import (
4+
"context"
5+
"encoding/base64"
6+
"encoding/json"
7+
"errors"
8+
"fmt"
9+
"path/filepath"
10+
"strings"
11+
"time"
12+
13+
a2atype "github.com/a2aproject/a2a-go/v2/a2a"
14+
a2ataskstore "github.com/a2aproject/a2a-go/v2/a2asrv/taskstore"
15+
"github.com/glebarez/sqlite"
16+
"gorm.io/gorm"
17+
)
18+
19+
const defaultPageSize = 50
20+
21+
type record struct {
22+
ID string `gorm:"primaryKey"`
23+
Task []byte
24+
Version int64
25+
User string `gorm:"index"`
26+
ContextID string `gorm:"index"`
27+
State string `gorm:"index"`
28+
StatusTimestamp *time.Time
29+
UpdatedAt time.Time `gorm:"index"`
30+
}
31+
32+
type Local struct {
33+
db *gorm.DB
34+
authenticator a2ataskstore.Authenticator
35+
}
36+
37+
var _ a2ataskstore.Store = (*Local)(nil)
38+
39+
func New(sessionDBURL string, authenticator a2ataskstore.Authenticator) (*Local, error) {
40+
path, err := pathFromSessionDBURL(sessionDBURL)
41+
if err != nil {
42+
return nil, err
43+
}
44+
db, err := gorm.Open(sqlite.Open(path), &gorm.Config{TranslateError: true})
45+
if err != nil {
46+
return nil, fmt.Errorf("open local task DB %q: %w", path, err)
47+
}
48+
if err := db.AutoMigrate(&record{}); err != nil {
49+
return nil, fmt.Errorf("migrate local task DB %q: %w", path, err)
50+
}
51+
return &Local{db: db, authenticator: authenticator}, nil
52+
}
53+
54+
func (s *Local) Create(ctx context.Context, task *a2atype.Task) (a2ataskstore.TaskVersion, error) {
55+
if task == nil {
56+
return a2ataskstore.TaskVersionMissing, fmt.Errorf("task cannot be nil")
57+
}
58+
user, err := s.user(ctx)
59+
if err != nil {
60+
return a2ataskstore.TaskVersionMissing, err
61+
}
62+
data, err := encode(task)
63+
if err != nil {
64+
return a2ataskstore.TaskVersionMissing, err
65+
}
66+
record := recordFromTask(task, data, user, 1)
67+
if err := s.db.WithContext(ctx).Create(&record).Error; errors.Is(err, gorm.ErrDuplicatedKey) {
68+
return a2ataskstore.TaskVersionMissing, a2ataskstore.ErrTaskAlreadyExists
69+
} else if err != nil {
70+
return a2ataskstore.TaskVersionMissing, fmt.Errorf("create local task: %w", err)
71+
}
72+
return 1, nil
73+
}
74+
75+
func (s *Local) Update(ctx context.Context, req *a2ataskstore.UpdateRequest) (a2ataskstore.TaskVersion, error) {
76+
if req == nil || req.Task == nil {
77+
return a2ataskstore.TaskVersionMissing, fmt.Errorf("update task cannot be nil")
78+
}
79+
user, err := s.user(ctx)
80+
if err != nil {
81+
return a2ataskstore.TaskVersionMissing, err
82+
}
83+
data, err := encode(req.Task)
84+
if err != nil {
85+
return a2ataskstore.TaskVersionMissing, err
86+
}
87+
query := s.db.WithContext(ctx).Model(&record{}).Where("id = ? AND user = ?", req.Task.ID, user)
88+
if req.PrevVersion != a2ataskstore.TaskVersionMissing {
89+
query = query.Where("version = ?", req.PrevVersion)
90+
}
91+
result := query.Updates(map[string]any{
92+
"task": data, "version": gorm.Expr("version + 1"), "context_id": req.Task.ContextID,
93+
"state": req.Task.Status.State, "status_timestamp": req.Task.Status.Timestamp, "updated_at": time.Now(),
94+
})
95+
if result.Error != nil {
96+
return a2ataskstore.TaskVersionMissing, fmt.Errorf("update local task: %w", result.Error)
97+
}
98+
if result.RowsAffected == 0 {
99+
var count int64
100+
if err := s.db.WithContext(ctx).Model(&record{}).Where("id = ? AND user = ?", req.Task.ID, user).Count(&count).Error; err != nil {
101+
return a2ataskstore.TaskVersionMissing, fmt.Errorf("check local task: %w", err)
102+
}
103+
if count == 0 {
104+
return a2ataskstore.TaskVersionMissing, a2atype.ErrTaskNotFound
105+
}
106+
return a2ataskstore.TaskVersionMissing, a2ataskstore.ErrConcurrentModification
107+
}
108+
var stored record
109+
if err := s.db.WithContext(ctx).Select("version").First(&stored, "id = ? AND user = ?", req.Task.ID, user).Error; err != nil {
110+
return a2ataskstore.TaskVersionMissing, fmt.Errorf("load updated local task: %w", err)
111+
}
112+
return a2ataskstore.TaskVersion(stored.Version), nil
113+
}
114+
115+
func (s *Local) Get(ctx context.Context, taskID a2atype.TaskID) (*a2ataskstore.StoredTask, error) {
116+
user, err := s.user(ctx)
117+
if err != nil {
118+
return nil, err
119+
}
120+
var stored record
121+
if err := s.db.WithContext(ctx).First(&stored, "id = ? AND user = ?", taskID, user).Error; errors.Is(err, gorm.ErrRecordNotFound) {
122+
return nil, a2atype.ErrTaskNotFound
123+
} else if err != nil {
124+
return nil, fmt.Errorf("load local task: %w", err)
125+
}
126+
task, err := decode(stored.Task)
127+
if err != nil {
128+
return nil, err
129+
}
130+
return &a2ataskstore.StoredTask{Task: task, Version: a2ataskstore.TaskVersion(stored.Version), User: stored.User}, nil
131+
}
132+
133+
func (s *Local) List(ctx context.Context, req *a2atype.ListTasksRequest) (*a2atype.ListTasksResponse, error) {
134+
user, err := s.user(ctx)
135+
if err != nil || user == "" {
136+
return nil, a2atype.ErrUnauthenticated
137+
}
138+
pageSize := req.PageSize
139+
if pageSize == 0 {
140+
pageSize = defaultPageSize
141+
} else if pageSize < 1 || pageSize > 100 {
142+
return nil, fmt.Errorf("page size must be between 1 and 100 inclusive, got %d: %w", pageSize, a2atype.ErrInvalidRequest)
143+
}
144+
query := s.db.WithContext(ctx).Model(&record{}).Where("user = ?", user)
145+
if req.ContextID != "" {
146+
query = query.Where("context_id = ?", req.ContextID)
147+
}
148+
if req.Status != a2atype.TaskStateUnspecified {
149+
query = query.Where("state = ?", req.Status)
150+
}
151+
if req.StatusTimestampAfter != nil {
152+
query = query.Where("status_timestamp IS NULL OR status_timestamp >= ?", req.StatusTimestampAfter)
153+
}
154+
var total int64
155+
if err := query.Count(&total).Error; err != nil {
156+
return nil, fmt.Errorf("count local tasks: %w", err)
157+
}
158+
if req.PageToken != "" {
159+
updatedAt, id, err := decodePageToken(req.PageToken)
160+
if err != nil {
161+
return nil, err
162+
}
163+
query = query.Where("updated_at < ? OR (updated_at = ? AND id < ?)", updatedAt, updatedAt, id)
164+
}
165+
var records []record
166+
if err := query.Order("updated_at DESC, id DESC").Limit(pageSize + 1).Find(&records).Error; err != nil {
167+
return nil, fmt.Errorf("list local tasks: %w", err)
168+
}
169+
nextPageToken := ""
170+
if len(records) > pageSize {
171+
last := records[pageSize-1]
172+
nextPageToken = encodePageToken(last.UpdatedAt, a2atype.TaskID(last.ID))
173+
records = records[:pageSize]
174+
}
175+
tasks := make([]*a2atype.Task, 0, len(records))
176+
for _, record := range records {
177+
task, err := decode(record.Task)
178+
if err != nil {
179+
return nil, err
180+
}
181+
shape(task, req)
182+
tasks = append(tasks, task)
183+
}
184+
return &a2atype.ListTasksResponse{Tasks: tasks, TotalSize: int(total), PageSize: pageSize, NextPageToken: nextPageToken}, nil
185+
}
186+
187+
func (s *Local) user(ctx context.Context) (string, error) {
188+
user, err := s.authenticator(ctx)
189+
if err != nil {
190+
return "", fmt.Errorf("taskstore auth failed: %w", err)
191+
}
192+
return user, nil
193+
}
194+
195+
func pathFromSessionDBURL(dbURL string) (string, error) {
196+
scheme, rest, ok := strings.Cut(dbURL, ":")
197+
if !ok || (scheme != "sqlite" && !strings.HasPrefix(scheme, "sqlite+")) {
198+
return "", fmt.Errorf("unsupported session DB URL %q: expected sqlite[+driver]:////<path>", dbURL)
199+
}
200+
path := "/" + strings.TrimLeft(rest, "/")
201+
if path == "/" {
202+
return "", fmt.Errorf("session DB URL %q has no path", dbURL)
203+
}
204+
return filepath.Join(filepath.Dir(path), "tasks.db"), nil
205+
}
206+
207+
func recordFromTask(task *a2atype.Task, data []byte, user string, version int64) record {
208+
return record{ID: string(task.ID), Task: data, Version: version, User: user, ContextID: task.ContextID, State: string(task.Status.State), StatusTimestamp: task.Status.Timestamp}
209+
}
210+
211+
func encode(task *a2atype.Task) ([]byte, error) {
212+
data, err := json.Marshal(task)
213+
if err != nil {
214+
return nil, fmt.Errorf("encode task: %w", err)
215+
}
216+
return data, nil
217+
}
218+
219+
func decode(data []byte) (*a2atype.Task, error) {
220+
var task a2atype.Task
221+
if err := json.Unmarshal(data, &task); err != nil {
222+
return nil, fmt.Errorf("decode task: %w", err)
223+
}
224+
return &task, nil
225+
}
226+
227+
func shape(task *a2atype.Task, req *a2atype.ListTasksRequest) {
228+
historyLength := 100
229+
if req.HistoryLength != nil {
230+
historyLength = *req.HistoryLength
231+
}
232+
if historyLength <= 0 {
233+
task.History = []*a2atype.Message{}
234+
} else if len(task.History) > historyLength {
235+
task.History = task.History[len(task.History)-historyLength:]
236+
}
237+
if !req.IncludeArtifacts {
238+
task.Artifacts = nil
239+
}
240+
}
241+
242+
func encodePageToken(updatedAt time.Time, id a2atype.TaskID) string {
243+
return base64.URLEncoding.EncodeToString(fmt.Appendf(nil, "%s_%s", updatedAt.Format(time.RFC3339Nano), id))
244+
}
245+
246+
func decodePageToken(token string) (time.Time, a2atype.TaskID, error) {
247+
decoded, err := base64.URLEncoding.DecodeString(token)
248+
if err != nil {
249+
return time.Time{}, "", a2atype.ErrParseError
250+
}
251+
timestamp, id, ok := strings.Cut(string(decoded), "_")
252+
if !ok {
253+
return time.Time{}, "", a2atype.ErrParseError
254+
}
255+
updatedAt, err := time.Parse(time.RFC3339Nano, timestamp)
256+
if err != nil {
257+
return time.Time{}, "", a2atype.ErrParseError
258+
}
259+
return updatedAt, a2atype.TaskID(id), nil
260+
}

0 commit comments

Comments
 (0)