Skip to content

Commit 3604a2a

Browse files
committed
feat: persistent memory system and ClawSchedule heartbeats
- Add ClawSchedule CRD for cron-based recurring tasks (heartbeat, scheduled, sweep) with concurrency policies (Forbid/Allow/Replace) and suspend support - Add MemorySpec to ClawInstance (enabled, maxSizeKB, systemPrompt) - ClawInstance controller creates/cleans up <instance>-memory ConfigMap - AgentRun controller mounts memory ConfigMap at /memory, extracts memory updates from pod logs via __K8SCLAW_MEMORY__ markers, patches ConfigMap - Agent-runner reads /memory/MEMORY.md at startup, prepends context to task, instructs LLM to emit memory updates, parses and emits markers - ClawSchedule controller with robfig/cron/v3 creates AgentRuns on schedule, injects memory context when includeMemory=true - TUI: schedules view (key 7), /schedule /schedules /memory commands, schedule row actions (describe, delete, logs) - RBAC: clawschedules permissions, configmap create/update/patch/delete - Fix .gitignore binary patterns to be root-relative (avoid ignoring internal/controller/) - Add 3 new memory tests (volumes, mount, env), all 42 tests pass
1 parent e538342 commit 3604a2a

18 files changed

+1938
-15
lines changed

.gitignore

Lines changed: 6 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,11 +1,11 @@
11
# Binaries
22
bin/
3-
agent-runner
4-
apiserver
5-
controller
6-
ipc-bridge
7-
webhook
8-
k8sclaw
3+
/agent-runner
4+
/apiserver
5+
/controller
6+
/ipc-bridge
7+
/webhook
8+
/k8sclaw
99
*.exe
1010
*.dll
1111
*.so

api/v1alpha1/clawinstance_types.go

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -27,6 +27,28 @@ type ClawInstanceSpec struct {
2727
// AuthRefs references secrets containing AI provider credentials.
2828
// +optional
2929
AuthRefs []SecretRef `json:"authRefs,omitempty"`
30+
31+
// Memory configures persistent memory for this instance.
32+
// When enabled, a MEMORY.md ConfigMap is managed and mounted into agent pods.
33+
// +optional
34+
Memory *MemorySpec `json:"memory,omitempty"`
35+
}
36+
37+
// MemorySpec configures persistent memory for a ClawInstance.
38+
type MemorySpec struct {
39+
// Enabled indicates whether persistent memory is active.
40+
// +kubebuilder:default=true
41+
Enabled bool `json:"enabled"`
42+
43+
// MaxSizeKB caps the memory ConfigMap size in kilobytes.
44+
// +kubebuilder:default=256
45+
// +optional
46+
MaxSizeKB int `json:"maxSizeKB,omitempty"`
47+
48+
// SystemPrompt is injected into every agent run for this instance
49+
// to instruct the agent on how to use memory.
50+
// +optional
51+
SystemPrompt string `json:"systemPrompt,omitempty"`
3052
}
3153

3254
// ChannelSpec defines a channel connection.

api/v1alpha1/clawschedule_types.go

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
package v1alpha1
2+
3+
import (
4+
metav1 "k8s.io/apimachinery/pkg/apis/meta/v1"
5+
)
6+
7+
// ClawScheduleSpec defines a recurring task for a ClawInstance.
8+
type ClawScheduleSpec struct {
9+
// InstanceRef is the name of the ClawInstance this schedule belongs to.
10+
InstanceRef string `json:"instanceRef"`
11+
12+
// Schedule is a cron expression (e.g. "*/5 * * * *").
13+
Schedule string `json:"schedule"`
14+
15+
// Task is the task description sent to the agent on each trigger.
16+
Task string `json:"task"`
17+
18+
// Type categorises the schedule: heartbeat, scheduled, or sweep.
19+
// +kubebuilder:validation:Enum=heartbeat;scheduled;sweep
20+
// +kubebuilder:default="scheduled"
21+
Type string `json:"type,omitempty"`
22+
23+
// Suspend pauses scheduling when true.
24+
// +optional
25+
Suspend bool `json:"suspend,omitempty"`
26+
27+
// ConcurrencyPolicy controls what happens when a trigger fires while
28+
// the previous run is still active.
29+
// +kubebuilder:validation:Enum=Forbid;Allow;Replace
30+
// +kubebuilder:default="Forbid"
31+
ConcurrencyPolicy string `json:"concurrencyPolicy,omitempty"`
32+
33+
// IncludeMemory injects the instance's MEMORY.md as context for each run.
34+
// +kubebuilder:default=true
35+
IncludeMemory bool `json:"includeMemory,omitempty"`
36+
}
37+
38+
// ClawScheduleStatus defines the observed state of a ClawSchedule.
39+
type ClawScheduleStatus struct {
40+
// Phase is the current phase (Active, Suspended, Error).
41+
// +optional
42+
Phase string `json:"phase,omitempty"`
43+
44+
// LastRunTime is when the last AgentRun was triggered.
45+
// +optional
46+
LastRunTime *metav1.Time `json:"lastRunTime,omitempty"`
47+
48+
// NextRunTime is the computed next trigger time.
49+
// +optional
50+
NextRunTime *metav1.Time `json:"nextRunTime,omitempty"`
51+
52+
// LastRunName is the name of the most recently created AgentRun.
53+
// +optional
54+
LastRunName string `json:"lastRunName,omitempty"`
55+
56+
// TotalRuns is the total number of runs triggered by this schedule.
57+
// +optional
58+
TotalRuns int64 `json:"totalRuns,omitempty"`
59+
60+
// Conditions represent the latest available observations.
61+
// +optional
62+
Conditions []metav1.Condition `json:"conditions,omitempty"`
63+
}
64+
65+
// +kubebuilder:object:root=true
66+
// +kubebuilder:subresource:status
67+
// +kubebuilder:printcolumn:name="Instance",type="string",JSONPath=".spec.instanceRef"
68+
// +kubebuilder:printcolumn:name="Schedule",type="string",JSONPath=".spec.schedule"
69+
// +kubebuilder:printcolumn:name="Type",type="string",JSONPath=".spec.type"
70+
// +kubebuilder:printcolumn:name="Phase",type="string",JSONPath=".status.phase"
71+
// +kubebuilder:printcolumn:name="Last Run",type="date",JSONPath=".status.lastRunTime"
72+
// +kubebuilder:printcolumn:name="Age",type="date",JSONPath=".metadata.creationTimestamp"
73+
74+
// ClawSchedule is the Schema for the clawschedules API.
75+
// It defines recurring tasks (heartbeats, scheduled jobs, sweeps) for a ClawInstance.
76+
type ClawSchedule struct {
77+
metav1.TypeMeta `json:",inline"`
78+
metav1.ObjectMeta `json:"metadata,omitempty"`
79+
80+
Spec ClawScheduleSpec `json:"spec,omitempty"`
81+
Status ClawScheduleStatus `json:"status,omitempty"`
82+
}
83+
84+
// +kubebuilder:object:root=true
85+
86+
// ClawScheduleList contains a list of ClawSchedule.
87+
type ClawScheduleList struct {
88+
metav1.TypeMeta `json:",inline"`
89+
metav1.ListMeta `json:"metadata,omitempty"`
90+
Items []ClawSchedule `json:"items"`
91+
}
92+
93+
func init() {
94+
SchemeBuilder.Register(&ClawSchedule{}, &ClawScheduleList{})
95+
}

api/v1alpha1/zz_generated.deepcopy.go

Lines changed: 116 additions & 0 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

cmd/agent-runner/main.go

Lines changed: 55 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -58,6 +58,31 @@ func main() {
5858
provider := strings.ToLower(getEnv("MODEL_PROVIDER", "openai"))
5959
modelName := getEnv("MODEL_NAME", "gpt-4o-mini")
6060
baseURL := strings.TrimRight(getEnv("MODEL_BASE_URL", ""), "/")
61+
memoryEnabled := getEnv("MEMORY_ENABLED", "") == "true"
62+
63+
// Read existing memory if available.
64+
var memoryContent string
65+
if memoryEnabled {
66+
if b, err := os.ReadFile("/memory/MEMORY.md"); err == nil {
67+
memoryContent = strings.TrimSpace(string(b))
68+
log.Printf("loaded memory (%d bytes)", len(memoryContent))
69+
}
70+
}
71+
72+
// Prepend memory context to the task if present.
73+
if memoryContent != "" && memoryContent != "# Agent Memory\n\nNo memories recorded yet." {
74+
task = fmt.Sprintf("## Your Memory\nThe following is your persistent memory from prior interactions:\n\n%s\n\n## Current Task\n%s", memoryContent, task)
75+
}
76+
77+
// If memory is enabled, add memory instructions to system prompt.
78+
if memoryEnabled {
79+
memoryInstruction := "\n\nYou have persistent memory. After completing your task, " +
80+
"output a memory update block wrapped in markers like this:\n" +
81+
"__K8SCLAW_MEMORY__\n<your updated MEMORY.md content>\n__K8SCLAW_MEMORY_END__\n" +
82+
"Include key facts, preferences, and context from this and past interactions. " +
83+
"Keep it concise (under 256KB). Use markdown format."
84+
systemPrompt += memoryInstruction
85+
}
6186

6287
apiKey := firstNonEmpty(
6388
os.Getenv("API_KEY"),
@@ -123,6 +148,14 @@ func main() {
123148
fmt.Fprintf(os.Stdout, "\n__K8SCLAW_RESULT__%s__K8SCLAW_END__\n", string(markerBytes))
124149
}
125150

151+
// Extract and emit memory update if the LLM produced one.
152+
if memoryEnabled && res.Response != "" {
153+
if memUpdate := extractMemoryUpdate(res.Response); memUpdate != "" {
154+
fmt.Fprintf(os.Stdout, "\n__K8SCLAW_MEMORY__%s__K8SCLAW_MEMORY_END__\n", memUpdate)
155+
log.Printf("emitted memory update (%d bytes)", len(memUpdate))
156+
}
157+
}
158+
126159
if res.Status == "error" {
127160
log.Printf("agent-runner finished with error: %s", res.Error)
128161
os.Exit(1)
@@ -269,3 +302,25 @@ func fatal(msg string) {
269302
})
270303
os.Exit(1)
271304
}
305+
306+
// extractMemoryUpdate looks for a memory update block in the LLM response.
307+
// The agent is instructed to wrap its memory updates in:
308+
//
309+
// __K8SCLAW_MEMORY__
310+
// <content>
311+
// __K8SCLAW_MEMORY_END__
312+
func extractMemoryUpdate(response string) string {
313+
const startMarker = "__K8SCLAW_MEMORY__"
314+
const endMarker = "__K8SCLAW_MEMORY_END__"
315+
316+
startIdx := strings.LastIndex(response, startMarker)
317+
if startIdx < 0 {
318+
return ""
319+
}
320+
payload := response[startIdx+len(startMarker):]
321+
endIdx := strings.Index(payload, endMarker)
322+
if endIdx < 0 {
323+
return ""
324+
}
325+
return strings.TrimSpace(payload[:endIdx])
326+
}

0 commit comments

Comments
 (0)