Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,7 @@ When documents differ, do not silently blend future design with current behavior
- Keep task lifecycle, agent activity, message delivery, user attention, workspace health, and publication state as separate concepts.
- Parallel work is limited by architectural cohesion, not just worker capacity. Resolve shared contracts and helpers before dispatching independent consumers.

Native Claude Code and Codex sessions run through the Substrate workspace adapter. Do not add the superseded worker or another workspace runtime.
Native Claude Code and Codex sessions run as kagent harness agents, from the `oldsj/kagent` fork, through the kagent A2A client in `backend/src/mainloop/runtime/`. The Substrate workspace adapter is being removed; do not extend it, and do not add the superseded worker or another workspace runtime.

## Project structure

Expand Down
10 changes: 5 additions & 5 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ You (phone/laptop)
▼
┌─────────────────────────────────────────────────────┐
│ Main Thread │
│ Native Claude Code session in Substrate │
│ Native Claude Code session via kagent │
│ │
│ user@mainloop$ research X ← inline sessions │
│ ├── [research X] thinking... ← threaded reply │
Expand All @@ -36,7 +36,7 @@ You (phone/laptop)
- **Main thread**: One continuous native conversation; delegated sessions surface results back
- **Sessions**: Native Claude Code or Codex work with their own conversations; appear as colored threads in your timeline
- **Notifications**: Slack-style thread replies notify you when sessions need attention or complete
- **Persistence**: Mainloop stores conversations, delivery records, and workspace lifecycle state in PostgreSQL; native history remains with the provider CLI in Substrate
- **Persistence**: Mainloop stores conversations, delivery records, and workspace lifecycle state in PostgreSQL; native history remains with the provider CLI in its kagent Session
- **Runtime isolation**: Substrate workspaces use gVisor actors. The pinned fork honors the
agent image's non-root UID `10001`; microVM isolation is deferred.

Expand Down Expand Up @@ -91,20 +91,20 @@ mainloop/

## Agent Workflow

Agents are native sessions spawned for development tasks. Mainloop records the session and its deliveries; the native CLI runs in the Substrate actor selected by the configured provider binding.
Agents are native sessions spawned for development tasks. Mainloop records the session and its deliveries; the native CLI session runs in a kagent Agent (A2A) selected by agent kind.

```text
┌─────────────┐ ┌─────────────┐ ┌─────────────┐ ┌─────────────┐
│ Spawn │────►│ Work │────►│ PR │────►│ Close │
│ (main) │ │(Substrate) │ │ (GitHub) │ │ (summary) │
│ (main) │ │ (kagent) │ │ (GitHub) │ │ (summary) │
└─────────────┘ └─────────────┘ └─────────────┘ └─────────────┘
▲ │
└───────────────────┘
check in / spawn more
```

1. **Spawn** - Main thread creates agent for a task
2. **Work** - A native Claude Code or Codex session runs in its configured Substrate actor
2. **Work** - A native Claude Code or Codex session runs in its kagent Agent
3. **PR** - Agent creates and merges GitHub PR when ready
4. **Close** - Agent posts summary back to main thread

Expand Down
5 changes: 4 additions & 1 deletion ROADMAP.md
Original file line number Diff line number Diff line change
Expand Up @@ -66,6 +66,8 @@ The control plane and worker workspaces have distinct responsibilities:

The workspace runtime is not an inference proxy. It does not rebuild model prompts, interpret provider tool calls, or replace a native agent's conversation loop.

The workspace runtime is kagent. Mainloop is an A2A client of the kagent gateway: each native session binding maps to one kagent Session, and Mainloop records every message in its delivery ledger before sending it. kagent owns agent pods, native harness processes, workspace bootstrap, snapshots and session expiry. Mainloop does not run a second scheduler or session owner alongside it.

## Context and continuity

The visible conversation may remain continuous while working context follows the active topic.
Expand Down Expand Up @@ -123,7 +125,7 @@ Failure handling distinguishes provider availability, transport errors, source d

## Workspace platform

The target execution platform provides one isolated writable workspace per concurrent writer, with persistent source and native-session state, bounded resources, scoped development services, preview endpoints, and observable lifecycle state. Reviewers may receive a read-only frozen candidate.
The target execution platform provides one isolated writable workspace per concurrent writer, with persistent source and native-session state, bounded resources, scoped development services, preview endpoints, and observable lifecycle state. Reviewers may receive a read-only frozen candidate. On kagent, a workspace is a Session with a Git bootstrap and snapshot-on-quiesce; previews reach it through the kagent router and idle out by suspending the Session.

Workspace identity does not depend on a process or pod name. Replacing workspace compute must preserve acknowledged product state and expose any gap in native or log recovery.

Expand Down Expand Up @@ -207,3 +209,4 @@ Exit criteria:
- Which local runners meet tool-use, context, identity, completion, and recovery requirements?
- What retention and restoration guarantees should apply to messages, logs, artifacts, and native state?
- Which workspace isolation and credential models are appropriate for different deployment profiles?
- How do agents call Mainloop tools (delegate, note, report, approvals) with per-session identity, without a credential inside the agent?
31 changes: 9 additions & 22 deletions backend/src/mainloop/api.py
Original file line number Diff line number Diff line change
Expand Up @@ -135,6 +135,9 @@ async def shutdown_event():
if task is not None:
task.cancel()
await asyncio.gather(task, return_exceptions=True)
from mainloop.runtime import native_sessions

await native_sessions.close_client()
await db.disconnect()


Expand Down Expand Up @@ -170,7 +173,6 @@ async def sse_events(
"""SSE endpoint for real-time updates.

Streams events for:
- task:updated - when a task status changes
- inbox:updated - when inbox items change
- heartbeat - periodic keepalive (every 30s)

Expand Down Expand Up @@ -234,8 +236,9 @@ async def chat(


async def _chat_native(request: ChatRequest, user_id: str) -> ChatResponse:
"""Record and deliver to the native main session through the Substrate workspace. The
reply is mirrored from the native journal, so the client polls the conversation."""
"""Record and deliver to the native main session through kagent. The reply is mirrored from
the A2A task, so the client polls the conversation.
"""
from mainloop.runtime import delegation, native_sessions

binding = await delegation.ensure_main_session(user_id)
Expand Down Expand Up @@ -269,10 +272,7 @@ async def get_main_thread_info(user_id: str = Header(alias="X-User-ID", default=
from mainloop.runtime import delegation, native_sessions

binding = await delegation.ensure_main_session(user_id)
# A rotation holds the session lock for the cut; do not queue behind it, so the UI can
# show "rotating" while it happens (the reconcile loop mirrors journal evidence anyway).
if not native_sessions.is_rotating(binding["session_id"]):
await native_sessions.sync(binding["session_id"])
await native_sessions.sync(binding["session_id"])
session = await db.get_session(binding["session_id"])
topics = await delegation._topic_lines(user_id)
return MainThreadInfo(
Expand All @@ -284,17 +284,6 @@ async def get_main_thread_info(user_id: str = Header(alias="X-User-ID", default=
)


@app.post("/main-thread/rotate")
async def rotate_main_thread(user_id: str = Header(alias="X-User-ID", default=None)):
"""Force a rotation now (same path as the automatic trigger); used to prove the cut."""
if not user_id:
user_id = get_user_id_from_cf_header()
from mainloop.runtime import delegation, native_sessions

binding = await delegation.ensure_main_session(user_id)
return await native_sessions.rotate(binding["session_id"], "manual")


@app.get("/topics")
async def list_topics(user_id: str = Header(alias="X-User-ID", default=None)):
"""Topic index with records (notes, decisions, pending intent, reports) for the UI."""
Expand Down Expand Up @@ -360,7 +349,7 @@ async def get_conversation(conversation_id: str):
WHERE b.role='main' AND s.conversation_id=$1""",
conversation_id,
)
if main_sid and not native_sessions.is_rotating(main_sid):
if main_sid:
await native_sessions.sync(main_sid)

messages = await db.get_messages(conversation_id)
Expand Down Expand Up @@ -719,9 +708,7 @@ async def get_session_conversation(session_id: str):
from mainloop.runtime import native_sessions

if await native_sessions.get_binding(session_id):
await native_sessions.sync(
session_id
) # mirror new native-journal evidence first
await native_sessions.sync(session_id) # observe the A2A task first
session = await db.get_session(session_id)

messages = await db.get_messages(session.conversation_id)
Expand Down
21 changes: 15 additions & 6 deletions backend/src/mainloop/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -77,13 +77,22 @@ def shim_token_secret_name(self, atespace: str, actor: str) -> str:
substrate_reauth_callback_url: str = "http://mainloop-backend:8000/internal/reauth"
substrate_reauth_timeout_seconds: int = 1800

# kagent: native Claude and Codex sessions run as kagent Agents behind one gateway
# (SessionService over grpc-web and A2A JSON-RPC). Mainloop acts as a fixed service identity.
kagent_gateway_url: str = "http://kagent-controller.kagent.svc.cluster.local:8083"
# kagent scopes Sessions to this identity. Changing it is a migration: every existing kagent
# Session becomes not found and is replaced, losing its native context.
kagent_user_id: str = "mainloop"
kagent_namespace: str = "kagent"
kagent_claude_agent: str = "claude-subscription"
kagent_codex_agent: str = "codex-subscription-https"
kagent_request_timeout_seconds: float = 30.0
kagent_turn_timeout_seconds: float = 1800.0
kagent_session_ready_timeout_seconds: float = 120.0
# "Send not accepted" is retried with the identical message for at most this long.
kagent_send_retry_budget_seconds: float = 30.0

# Native main thread (context model).
main_thread_model: str = "sonnet"
main_thread_effort: str = "medium"
# Rotation: cut to a fresh native session when the context grew by this many tokens above
# the lineage's first-turn baseline, or after this many completed turns (whichever first).
main_rotate_tokens: int = 20000
main_rotate_turns: int = 12
main_carry_over_messages: int = 6
native_child_kinds: str = "claude,codex"
agent_token_key: str = (
Expand Down
Loading
Loading