You signed in with another tab or window. Reload to refresh your session.You signed out in another tab or window. Reload to refresh your session.You switched accounts on another tab or window. Reload to refresh your session.Dismiss alert
{{ message }}
Repository navigation
ChatWorkflowRunner: multi-turn chat as dynamic workflow #145
The executor stays stateless — it receives context and returns results. The caller manages the conversation lifecycle.
Implementations
Consumer
Backend
lightspeed-stack
Existing `Cache` (SQLite/PostgreSQL)
cloud-agents CLI
In-memory (session-scoped)
cloud-agents workflows
`RunStateStore` (already persists step results)
Integration with StepInput
Add `conversation_id` to `StepInput.metadata` (see #144) rather than as a top-level field. The conversation store reads it from metadata in the middleware layer.
Alternative: multi-turn chat as a dynamic workflow
Instead of a separate ConversationStore ABC, treat a multi-turn chat as a workflow with dynamically appended steps. Each user message is one step executed via DirectExecutor, with prior turn outputs flowing as context.
Why this works
The existing infrastructure already supports it:
RunStateStore accumulates step results per workflow — that IS conversation history
TranscriptStore records execution details per step — that IS per-turn audit
StepInput.context passes all prior step outputs — that IS conversation context for the LLM
workflow_id becomes conversation_id
What changes
Instead of building a new store, we need a ConversationalExecutor — a thin wrapper that accepts one message at a time and accumulates in RunStateStore:
classConversationalExecutor:
asyncdefsend_message(self, conversation_id: str, prompt: str) ->StepResult:
"""Execute one turn, with all prior turns as context."""prior=awaitself.store.get(conversation_id)
step_input=StepInput(
prompt=prompt,
context=prior.steps, # all prior turnstools=self.config.tools,
mcp_servers=self.config.mcp_servers,
provider=self.config.provider,
)
result=awaitself.executor.run(step_input)
awaitself.store.update_step(conversation_id, f"turn-{n}", result)
returnresultasyncdefsend_message_stream(self, conversation_id: str, prompt: str) ->AsyncIterator[StreamEvent]:
"""Same, but streaming."""# ... same pattern with executor.run_stream()
No separate conversation cache needed. RunStateStore (PostgreSQL, already shared with LCS) is the store.
Recommendation
Keep this issue open but reframe it: instead of ConversationStore ABC, implement ConversationalExecutor that reuses RunStateStore + StepExecutor. The original ConversationStore proposal becomes a fallback if the RunStateStore approach proves too heavyweight for simple chatbot use cases.
Refined alternative: conversation history as a view on transcripts
Building on the "chat as dynamic workflow" alternative, conversation history is not a separate store — it's a projected view on transcript data.
The data model
Transcript (full detail per step/turn)
├── LLM request: prompt sent to model
├── LLM response: model output
├── Tool call: name + args
├── Tool result: output
├── MCP call: server + tool + result
└── Timing, tokens, cost
Conversation history (filtered view)
├── Turn 1: user prompt → agent response
├── Turn 2: user prompt → agent response
└── Turn 3: user prompt → agent response
Three abstractions eliminated
Original proposal
Becomes
ConversationStore
Query on TranscriptStore
conversation_id
= workflow_id
Conversation history
= Transcript filtered to prompts + outputs
How it works
Each conversation is one workflow. Each turn is one step with a detailed transcript.
# Building LLM context for next turnprior_turns=awaittranscript_store.list_steps(conversation_id)
messages= []
forturninprior_turns:
messages.append({"role": "user", "content": turn.prompt})
messages.append({"role": "assistant", "content": turn.output})
The TranscriptStore already has:
save(workflow_id, step_name, events, cost, tokens, duration) — stores one turn
list_steps(workflow_id) — lists all turns in a conversation
get(workflow_id, step_name) — gets one turn's detail
PostgreSQL backend with configurable retention
What TranscriptStore needs
To serve as conversation history, it needs:
Store prompt alongside transcript events — currently only stores events (JSONB), not the original prompt. Add prompt and output columns, or include them as transcript event types.
Efficient conversation loading — list_steps() returns step names only. Need a query that returns prompt + output for all turns without loading full event logs.
Message-history format — a helper that converts stored turns to pydantic-ai message_history format for Agent.run(message_history=...).
Dependency:#146 (identity and association model) provides session_id, parent_workflow_id, and TranscriptStore schema extensions (prompt/output columns) that the conversational executor needs. #146 should land first.
The most critical design question is whether pydantic-ai Agent.run(message_history=...) works for multi-turn conversation. Current StepInput.context flattens prior turns to text — this loses the structured role/content format LLMs need.
Before building ConversationalExecutor, prototype:
# Load prior turns from TranscriptStoreturns=awaittranscript_store.load_recent_turns(conversation_id, limit=20)
# Convert to pydantic-ai ModelMessage objectsfrompydantic_ai.messagesimportModelRequest, ModelResponsemessage_history= []
forturninturns:
message_history.append(ModelRequest.user_text_prompt(turn.prompt))
# Need to reconstruct ModelResponse from stored output
...
# Feed to Agentresult=awaitagent.run(new_prompt, message_history=message_history)
If ModelMessage objects can be serialized/deserialized to/from the TranscriptStore schema, the architecture works. If not, we need to store the raw pydantic-ai message format.
Context window management
Add max_context_turns config (default 20) + load_recent_turns() query. For longer conversations, consider lightspeed-stack's conversation_compaction.py which summarizes older turns.
Store orchestration
ConversationalExecutor explicitly uses BOTH stores:
RunStateStore — create/update workflow lifecycle (status, current turn)
Prototyped pydantic-ai's Agent.run(message_history=...) — the architecture is viable.
What was proven
Step
Result
Reconstruct ModelRequest/ModelResponse from stored data
Works
Serialize via TypeAdapter(list[ModelMessage]).dump_json()
Works — ~850 bytes/turn
Deserialize back to typed objects
Round-trips perfectly
Agent.run(message_history=restored) accepts the type
Works
result.all_messages() returns prior + new messages
Works by design
Design decision: store serialized ModelMessage JSON (Option B)
Store the full pydantic-ai message objects in TranscriptStore, not just prompt/output text. This preserves tool call details — the LLM sees that it previously called kubectl_get and got a specific result, not just a text summary.
Design revision: framework-agnostic message format
Problem
Storing pydantic-ai ModelMessage JSON directly couples our persistence layer to pydantic-ai's internal types. If we swap frameworks, the stored data becomes unreadable.
Fix: our own message format + adapter at the boundary
@dataclassclassConversationMessage:
"""Framework-agnostic conversation message. This is OUR format — stable across framework changes. """role: Literal["user", "assistant", "tool_call", "tool_result"]
content: strtimestamp: datetimemetadata: dict[str, Any] =field(default_factory=dict)
# metadata carries: tool_name, tool_args, model, tokens, etc.
Adapter pattern (same as ToolDefinition → pydantic_ai.Tool)
The JSONB stores our ConversationMessage format. Loading for any framework goes through the adapter. The prototype validation still holds — we just add a conversion layer.
Consistency with existing patterns
Our abstraction
Framework type
Where
ToolDefinition
pydantic_ai.Tool
tools.py — get_tools() converts at runtime
ConversationMessage
ModelMessage
adapter — converts at load/save
StepExecutor
pydantic_ai.Agent
executor — wraps at execution
Same principle throughout: our contract is stable, framework is pluggable.
"ConversationalExecutor" implies it's a StepExecutor subclass (like DirectExecutor, SubprocessExecutor). It's not — it's a higher-level wrapper that manages turn state and delegates to a real executor.
What it actually does
Load prior turns from TranscriptStore
Convert ConversationMessage[] → pydantic-ai ModelMessage[] via adapter
Build StepInput with conversation context
Delegate to StepExecutor.run() or run_stream() for the actual LLM call
Convert new messages → ConversationMessage[] and save to TranscriptStore
Update workflow lifecycle in RunStateStore
It manages a session, not an execution.
Interface
classAgentSession:
"""Multi-turn agent session backed by TranscriptStore."""def__init__(
self,
executor: StepExecutor,
transcript_store: TranscriptStore,
run_store: RunStateStore,
config: AgentSessionConfig,
): ...
asyncdefsend_message(
self, conversation_id: str, prompt: str
) ->StepResult:
"""Execute one turn with all prior turns as context."""asyncdefsend_message_stream(
self, conversation_id: str, prompt: str
) ->AsyncIterator[StreamEvent]:
"""Same, but streaming."""asyncdefget_history(
self, conversation_id: str, limit: int=20
) ->list[ConversationMessage]:
"""Load recent conversation history."""asyncdefcreate(self, user_id: str) ->str:
"""Create a new conversation. Returns conversation_id."""asyncdefclose(self, conversation_id: str) ->None:
"""Mark conversation as completed."""
How lightspeed-stack uses it
# In /query endpointsession=AgentSession(executor, transcript_store, run_store, config)
# New conversationconv_id=awaitsession.create(user_id=caller.user_id)
# Each user messageasyncforeventinsession.send_message_stream(conv_id, user_prompt):
yieldsse_format(event)
WorkflowRunner — predefined YAML steps (pydantic-graph)
TemporalWorkflowRunner — predefined steps with crash recovery (Temporal)
ChatWorkflowRunner — dynamic steps from user messages
└── all three delegate to StepExecutor for individual steps
"ChatWorkflowRunner" because:
Peer naming with WorkflowRunner/TemporalWorkflowRunner
"Chat" says what drives it (user messages)
"WorkflowRunner" says what it is (an orchestrator over StepExecutor)
Reinforces "chat IS a workflow" — it's in the name
Updated interface
classChatWorkflowRunner:
"""Multi-turn chat as a dynamic workflow."""def__init__(
self,
executor: StepExecutor,
transcript_store: TranscriptStore,
run_store: RunStateStore,
config: ChatWorkflowConfig,
): ...
asyncdefcreate(self, user_id: str) ->str:
"""Create a new conversation workflow. Returns conversation_id."""asyncdefsend_message(self, conversation_id: str, prompt: str) ->StepResult:
"""Execute one turn with prior turns as message_history."""asyncdefsend_message_stream(
self, conversation_id: str, prompt: str
) ->AsyncIterator[StreamEvent]:
"""Same, but streaming."""asyncdefget_history(
self, conversation_id: str, limit: int=20
) ->list[ConversationMessage]:
"""Load recent conversation history."""asyncdefclose(self, conversation_id: str) ->None:
"""Mark conversation workflow as completed."""
Closing ConversationStore as an ABC is the right call inside this repo. Do not reopen it. ChatWorkflowRunner + transcript view is the cloud-agents design.
It is the wrong integration story for lightspeed-stack, and that needs to be explicit here so #125 does not swallow LCS's cache.
Do
Prototype is done; implementation can proceed on: framework-agnostic ConversationMessage, max_context_turns, load_recent_turns(), GDPR delete endpoint.
Store serialized our messages, not raw pydantic-ai ModelMessage JSON (the later revision). Adapter at the Agent boundary.
ChatWorkflowRunner uses both stores: RunStateStore for lifecycle, TranscriptStore for turns. Good.
Do not
Equate conversation_id with workflow_id at the LCS boundary. LCS already has conversation_cache (SQLite/Postgres), compaction (conversation_compaction.py), list/delete conversation APIs, and Action.QUERY_OTHERS_CONVERSATIONS. Mapping 1:1 couples chat retention to workflow-run retention and bypasses compaction.
Put conversation load/save inside DirectExecutor. The later "caller owns the loop" pattern is correct; keep it.
Treat this as a Phase 1 Design: Embed cloud agents harness into lightspeed-stack as optional feature #125 dependency. Mounting /v1/workflows does not need a chat runner. LCS /query parity is a later adapter: LCS cache → ConversationMessage[] → ChatWorkflowRunner.send_message → map SSE to format_stream_data. That adapter lives in LCS, not here.
Missing from the issue body (update it)
The title still says ConversationStore ABC. Body + comments disagree. Rewrite the title/checklist to ChatWorkflowRunner or people will implement the ABC from the first revision.
Also specify: concurrent turns on the same conversation_id (last-write-wins vs 409), and that message_history growth must be bounded before this is used as /query. max_context_turns=20 is a start; LCS compaction is the actual long-thread strategy and should stay in LCS.
changed the title [-]ConversationStore ABC for multi-turn agent sessions[/-][+]ChatWorkflowRunner: multi-turn chat as dynamic workflow[/+]on Aug 23, 2026
Clarification: ChatWorkflowRunner works in ALL modes
ChatWorkflowRunner is available in both standalone and embedded deployments. The unification of chat and workflows IS the design goal — not something to avoid in embedded mode.
When embedded in lightspeed-stack:
ChatWorkflowRunner is an option teams can use for /query
It brings the unified model: chat = workflow, same executor stack, same tools/MCP/skills
Cloud-agents provides the capability. LCS decides how to adopt it.
The earlier comment about "NOT a Phase 1 #125 dependency" still stands — mounting /v1/workflows doesn't require ChatWorkflowRunner. But ChatWorkflowRunner is available for teams that want unified chat+workflow from day one.
Activity
beesarmy commentedon Aug 23, 2026
Triage
Summary: Shared ConversationStore ABC for multi-turn agent sessions across lightspeed-stack, cloud-agents CLI, and workflow steps.
Affected components:
src/cloud_agents/workflow/executor/step/direct.py— DirectExecutor would use ConversationStore for multi-turn contextsrc/cloud_agents/workflow/executor/step/base.py— StepInput may need conversation_id fieldsrc/cloud_agents/storage/conversation_store.py— ABC + PostgreSQL implementationRelated issues/PRs:
Suggested labels:
enhancement🤖 Triaged by beesarmy via Claude Code
jameswnl commentedon Aug 23, 2026
Alternative: multi-turn chat as a dynamic workflow
Instead of a separate
ConversationStoreABC, treat a multi-turn chat as a workflow with dynamically appended steps. Each user message is one step executed via DirectExecutor, with prior turn outputs flowing as context.Why this works
The existing infrastructure already supports it:
RunStateStoreaccumulates step results per workflow — that IS conversation historyTranscriptStorerecords execution details per step — that IS per-turn auditStepInput.contextpasses all prior step outputs — that IS conversation context for the LLMworkflow_idbecomesconversation_idWhat changes
Instead of building a new store, we need a ConversationalExecutor — a thin wrapper that accepts one message at a time and accumulates in
RunStateStore:Two modes, one engine
What this means for lightspeed-stack integration
The
/queryendpoint becomes:No separate conversation cache needed.
RunStateStore(PostgreSQL, already shared with LCS) is the store.Recommendation
Keep this issue open but reframe it: instead of
ConversationStoreABC, implementConversationalExecutorthat reusesRunStateStore+StepExecutor. The originalConversationStoreproposal becomes a fallback if the RunStateStore approach proves too heavyweight for simple chatbot use cases.jameswnl commentedon Aug 23, 2026
Refined alternative: conversation history as a view on transcripts
Building on the "chat as dynamic workflow" alternative, conversation history is not a separate store — it's a projected view on transcript data.
The data model
Three abstractions eliminated
ConversationStoreTranscriptStoreconversation_idworkflow_idHow it works
Each conversation is one workflow. Each turn is one step with a detailed transcript.
The
TranscriptStorealready has:save(workflow_id, step_name, events, cost, tokens, duration)— stores one turnlist_steps(workflow_id)— lists all turns in a conversationget(workflow_id, step_name)— gets one turn's detailWhat TranscriptStore needs
To serve as conversation history, it needs:
events(JSONB), not the original prompt. Addpromptandoutputcolumns, or include them as transcript event types.list_steps()returns step names only. Need a query that returns prompt + output for all turns without loading full event logs.message_historyformat forAgent.run(message_history=...).Recommendation
Close this issue in favor of:
TranscriptStoreschema to include prompt/output columns (StepMiddleware protocol for cross-cutting executor concerns #144 middleware stores these)load_conversation_context(workflow_id)query methodConversationalExecutorusesTranscriptStoredirectly — no new store ABC neededjameswnl commentedon Aug 23, 2026
Dependency: #146 (identity and association model) provides
session_id,parent_workflow_id, and TranscriptStore schema extensions (prompt/outputcolumns) that the conversational executor needs. #146 should land first.jameswnl commentedon Aug 23, 2026
Opus review revisions
Prototype message_history first
The most critical design question is whether pydantic-ai
Agent.run(message_history=...)works for multi-turn conversation. CurrentStepInput.contextflattens prior turns to text — this loses the structured role/content format LLMs need.Before building ConversationalExecutor, prototype:
If
ModelMessageobjects can be serialized/deserialized to/from the TranscriptStore schema, the architecture works. If not, we need to store the raw pydantic-ai message format.Context window management
Add
max_context_turnsconfig (default 20) +load_recent_turns()query. For longer conversations, consider lightspeed-stack'sconversation_compaction.pywhich summarizes older turns.Store orchestration
ConversationalExecutorexplicitly uses BOTH stores:RunStateStore— create/update workflow lifecycle (status, current turn)TranscriptStore— save/load turn content (prompt, output, events)Concurrent conversations
Works — each conversation gets a unique
workflow_id. Same user can have multiple conversations simultaneously. No conflict.GDPR deletion
RunStateStore.delete()+TranscriptStore.delete_workflow()already exist. Need a/v1/conversations/{id}DELETE endpoint. Track as follow-up.jameswnl commentedon Aug 23, 2026
message_history prototype: VALIDATED
Prototyped pydantic-ai's
Agent.run(message_history=...)— the architecture is viable.What was proven
ModelRequest/ModelResponsefrom stored dataTypeAdapter(list[ModelMessage]).dump_json()Agent.run(message_history=restored)accepts the typeresult.all_messages()returns prior + new messagesDesign decision: store serialized ModelMessage JSON (Option B)
Store the full pydantic-ai message objects in TranscriptStore, not just prompt/output text. This preserves tool call details — the LLM sees that it previously called
kubectl_getand got a specific result, not just a text summary.Schema change:
ConversationalExecutor flow:
Why Option B over prompt/output text:
result.all_messages()returns the complete history including the new turn — store once, load onceContext window management:
max_context_turnsconfig limits how many prior turns to loadmessage_historyconversation_compaction.pypattern)jameswnl commentedon Aug 23, 2026
Design revision: framework-agnostic message format
Problem
Storing pydantic-ai
ModelMessageJSON directly couples our persistence layer to pydantic-ai's internal types. If we swap frameworks, the stored data becomes unreadable.Fix: our own message format + adapter at the boundary
Adapter pattern (same as ToolDefinition → pydantic_ai.Tool)
What this means for storage
The JSONB stores our
ConversationMessageformat. Loading for any framework goes through the adapter. The prototype validation still holds — we just add a conversion layer.Consistency with existing patterns
ToolDefinitionpydantic_ai.Tooltools.py—get_tools()converts at runtimeConversationMessageModelMessageStepExecutorpydantic_ai.AgentSame principle throughout: our contract is stable, framework is pluggable.
jameswnl commentedon Aug 23, 2026
Naming revision: ConversationalExecutor → AgentSession
Why rename
"ConversationalExecutor" implies it's a
StepExecutorsubclass (like DirectExecutor, SubprocessExecutor). It's not — it's a higher-level wrapper that manages turn state and delegates to a real executor.What it actually does
ConversationMessage[]→ pydantic-aiModelMessage[]via adapterStepInputwith conversation contextStepExecutor.run()orrun_stream()for the actual LLM callConversationMessage[]and save to TranscriptStoreIt manages a session, not an execution.
Interface
How lightspeed-stack uses it
Naming consistency
StepExecutorAgentSessionLocalExecutorAgentSessionis to conversations whatLocalExecutoris to predefined workflows — a higher-level orchestrator that usesStepExecutorunder the hood.jameswnl commentedon Aug 23, 2026
Final naming: AgentSession → ChatWorkflowRunner
Naming hierarchy
"ChatWorkflowRunner" because:
Updated interface
How lightspeed-stack uses it
All previous references to "ConversationalExecutor" and "AgentSession" in this issue are superseded by
ChatWorkflowRunner.jameswnl commentedon Aug 23, 2026
Review
Closing
ConversationStoreas an ABC is the right call inside this repo. Do not reopen it.ChatWorkflowRunner+ transcript view is the cloud-agents design.It is the wrong integration story for lightspeed-stack, and that needs to be explicit here so #125 does not swallow LCS's cache.
Do
ConversationMessage,max_context_turns,load_recent_turns(), GDPR delete endpoint.ModelMessageJSON (the later revision). Adapter at the Agent boundary.ChatWorkflowRunneruses both stores:RunStateStorefor lifecycle,TranscriptStorefor turns. Good.Do not
conversation_idwithworkflow_idat the LCS boundary. LCS already hasconversation_cache(SQLite/Postgres), compaction (conversation_compaction.py), list/delete conversation APIs, andAction.QUERY_OTHERS_CONVERSATIONS. Mapping 1:1 couples chat retention to workflow-run retention and bypasses compaction.DirectExecutor. The later "caller owns the loop" pattern is correct; keep it./v1/workflowsdoes not need a chat runner. LCS/queryparity is a later adapter: LCS cache →ConversationMessage[]→ChatWorkflowRunner.send_message→ map SSE toformat_stream_data. That adapter lives in LCS, not here.Missing from the issue body (update it)
The title still says
ConversationStore ABC. Body + comments disagree. Rewrite the title/checklist toChatWorkflowRunneror people will implement the ABC from the first revision.Also specify: concurrent turns on the same
conversation_id(last-write-wins vs 409), and thatmessage_historygrowth must be bounded before this is used as/query.max_context_turns=20is a start; LCS compaction is the actual long-thread strategy and should stay in LCS.[-]ConversationStore ABC for multi-turn agent sessions[/-][+]ChatWorkflowRunner: multi-turn chat as dynamic workflow[/+]jameswnl commentedon Aug 23, 2026
Acknowledged — title fixed, scope clarified
Title updated to "ChatWorkflowRunner: multi-turn chat as dynamic workflow". ConversationStore ABC is dead.
Accepted:
ConversationMessageformat,max_context_turns,load_recent_turns()ChatWorkflowRunneruses both stores (RunStateStore lifecycle, TranscriptStore turns)NOT a Phase 1 #125 dependency. Embedding
/v1/workflowsdoesn't need a chat runner. LCS/queryparity is a later adapter that lives in LCS, not here.Do not equate
conversation_id=workflow_idat the LCS boundary — LCS has its own conversation cache with compaction, list/delete APIs, and RBAC.jameswnl commentedon Aug 23, 2026
Clarification: ChatWorkflowRunner works in ALL modes
ChatWorkflowRunner is available in both standalone and embedded deployments. The unification of chat and workflows IS the design goal — not something to avoid in embedded mode.
When embedded in lightspeed-stack:
/queryCloud-agents provides the capability. LCS decides how to adopt it.
The earlier comment about "NOT a Phase 1 #125 dependency" still stands — mounting
/v1/workflowsdoesn't require ChatWorkflowRunner. But ChatWorkflowRunner is available for teams that want unified chat+workflow from day one.Merge pull request #152 from jameswnl/issue-145-chat-workflow-runner
Merge pull request #153 from jameswnl/issue-145-remaining-fixes
Merge pull request #154 from jameswnl/issue-145-final-fixes
Merge pull request #157 from jameswnl/fix/chat-correctness