markov-agent 1.4.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -0,0 +1,76 @@
1
+ Metadata-Version: 2.3
2
+ Name: markov-agent
3
+ Version: 1.4.0
4
+ Summary: A coding agent following SKILL.state paper principles.
5
+ Author: Nacho Llorca
6
+ Author-email: Nacho Llorca <nllorca@proton.me>
7
+ License: MIT
8
+ Requires-Dist: lmdk>=2.10.3
9
+ Requires-Dist: textual>=6.0.0
10
+ Requires-Python: >=3.13
11
+ Description-Content-Type: text/markdown
12
+
13
+ # markov-agent
14
+
15
+ A coding agent following the principles defined in the [SKILL.state](docs/paper.md) paper:
16
+
17
+ > Runtime architecture that replaces append-only conversational history with an explicit, mutable execution state. At each execution step, the model receives only the immutable skill specification, the current structured execution state, and the latest observation. Intermediate reasoning is discarded immediately after **producing a validated state update**, preventing prompt growth with execution history.
18
+
19
+ ![Figure](docs/figure.png)
20
+
21
+ Hence, the context given to the agent is modeled similar to a Markov process: the next state depends only on the current one and the (observed result of the) action taken.
22
+
23
+ ## Overview
24
+
25
+ Although the paper specifies tasks like inventory management or CTF, I wanted to use it for a coding agent and test it in tasks I normally throw at [Pi](https://github.com/earendil-works/pi) and see what happens.
26
+
27
+ The action space is: **bash**. Nothing more and nothing less. While most coding agents have some specially guardrailed tools (`read`, `patch`, etc.), [there is research proving that powerful LMs can do better without](https://arxiv.org/pdf/2609.20804):
28
+
29
+ > Predefined tools improve performance for models with weaker bash proficiency, whereas bash-capable models can operate effectively with a bash-only interface and achieve substantially lower cost, especially on command-line-centric tasks.
30
+
31
+ ## Installation
32
+
33
+ ```bash
34
+ uv add markov-agent
35
+ ```
36
+
37
+ ## Usage
38
+
39
+ ### TUI
40
+
41
+ ```bash
42
+ uv tool install markov-agent
43
+ markov # --model defaults to mistral:mistral-large-4
44
+ ```
45
+
46
+ ### As a library
47
+
48
+ [`skill_state.py`](src/markov_agent/skill_state.py) is the whole implementation of the paper. Define your
49
+ state as a pydantic model plus an immutable instruction block, then consume the `StepEvent`s of
50
+ `run()`. [`agent.py`](src/markov_agent/agent.py) is a small coding-agent domain to copy from.
51
+
52
+ If you only want the core and not the terminal UI (nor its `textual` dependency), copy
53
+ `skill_state.py` and `prompt_template.jinja` into your project: they import nothing else from this
54
+ package, and `lmdk` + `jinja2` are their only requirements.
55
+
56
+ ## Development
57
+
58
+ This project uses [uv](https://docs.astral.sh/uv/), [just](https://just.systems/) and [prek](https://github.com/j178/prek).
59
+
60
+ See [justfile](justfile) for pre-built commands used along pre-commit hooks and CI.
61
+
62
+ _Made with [`mold`](https://github.com/nachollorca/mold) template_
63
+
64
+ ## References
65
+
66
+ ```
67
+ @misc{badhe2026skillstatescalablelonghorizonagent,
68
+ title={SKILL.state: Scalable Long-Horizon Agent Skills},
69
+ author={Sanket Badhe and Priyanka Tiwari and Jonghyun Chung},
70
+ year={2026},
71
+ eprint={2608.26263},
72
+ archivePrefix={arXiv},
73
+ primaryClass={cs.AI},
74
+ url={https://arxiv.org/abs/2608.26263},
75
+ }
76
+ ```
@@ -0,0 +1,64 @@
1
+ # markov-agent
2
+
3
+ A coding agent following the principles defined in the [SKILL.state](docs/paper.md) paper:
4
+
5
+ > Runtime architecture that replaces append-only conversational history with an explicit, mutable execution state. At each execution step, the model receives only the immutable skill specification, the current structured execution state, and the latest observation. Intermediate reasoning is discarded immediately after **producing a validated state update**, preventing prompt growth with execution history.
6
+
7
+ ![Figure](docs/figure.png)
8
+
9
+ Hence, the context given to the agent is modeled similar to a Markov process: the next state depends only on the current one and the (observed result of the) action taken.
10
+
11
+ ## Overview
12
+
13
+ Although the paper specifies tasks like inventory management or CTF, I wanted to use it for a coding agent and test it in tasks I normally throw at [Pi](https://github.com/earendil-works/pi) and see what happens.
14
+
15
+ The action space is: **bash**. Nothing more and nothing less. While most coding agents have some specially guardrailed tools (`read`, `patch`, etc.), [there is research proving that powerful LMs can do better without](https://arxiv.org/pdf/2609.20804):
16
+
17
+ > Predefined tools improve performance for models with weaker bash proficiency, whereas bash-capable models can operate effectively with a bash-only interface and achieve substantially lower cost, especially on command-line-centric tasks.
18
+
19
+ ## Installation
20
+
21
+ ```bash
22
+ uv add markov-agent
23
+ ```
24
+
25
+ ## Usage
26
+
27
+ ### TUI
28
+
29
+ ```bash
30
+ uv tool install markov-agent
31
+ markov # --model defaults to mistral:mistral-large-4
32
+ ```
33
+
34
+ ### As a library
35
+
36
+ [`skill_state.py`](src/markov_agent/skill_state.py) is the whole implementation of the paper. Define your
37
+ state as a pydantic model plus an immutable instruction block, then consume the `StepEvent`s of
38
+ `run()`. [`agent.py`](src/markov_agent/agent.py) is a small coding-agent domain to copy from.
39
+
40
+ If you only want the core and not the terminal UI (nor its `textual` dependency), copy
41
+ `skill_state.py` and `prompt_template.jinja` into your project: they import nothing else from this
42
+ package, and `lmdk` + `jinja2` are their only requirements.
43
+
44
+ ## Development
45
+
46
+ This project uses [uv](https://docs.astral.sh/uv/), [just](https://just.systems/) and [prek](https://github.com/j178/prek).
47
+
48
+ See [justfile](justfile) for pre-built commands used along pre-commit hooks and CI.
49
+
50
+ _Made with [`mold`](https://github.com/nachollorca/mold) template_
51
+
52
+ ## References
53
+
54
+ ```
55
+ @misc{badhe2026skillstatescalablelonghorizonagent,
56
+ title={SKILL.state: Scalable Long-Horizon Agent Skills},
57
+ author={Sanket Badhe and Priyanka Tiwari and Jonghyun Chung},
58
+ year={2026},
59
+ eprint={2608.26263},
60
+ archivePrefix={arXiv},
61
+ primaryClass={cs.AI},
62
+ url={https://arxiv.org/abs/2608.26263},
63
+ }
64
+ ```
@@ -0,0 +1,71 @@
1
+ [project]
2
+ name = "markov-agent"
3
+ version = "1.4.0"
4
+ description = "A coding agent following SKILL.state paper principles."
5
+ requires-python = ">=3.13"
6
+ readme = "README.md"
7
+ dependencies = [
8
+ "lmdk>=2.10.3",
9
+ "textual>=6.0.0",
10
+ ]
11
+
12
+ [project.license]
13
+ text = "MIT"
14
+
15
+ [[project.authors]]
16
+ name = "Nacho Llorca"
17
+ email = "nllorca@proton.me"
18
+
19
+ [project.scripts]
20
+ markov = "markov_agent.tui:main"
21
+
22
+ [dependency-groups]
23
+ dev = [
24
+ "logfire",
25
+ "complexipy",
26
+ "pytest-cov",
27
+ "ruff",
28
+ "ty",
29
+ ]
30
+
31
+ [build-system]
32
+ requires = ["uv_build>=0.11.1,<0.12.0"]
33
+ build-backend = "uv_build"
34
+
35
+ [tool.ruff]
36
+ fix = true
37
+ line-length = 100
38
+
39
+ [tool.ruff.lint]
40
+ select = [
41
+ "D",
42
+ "E",
43
+ "F",
44
+ "I",
45
+ "UP",
46
+ "B",
47
+ "SIM",
48
+ "RUF",
49
+ ]
50
+ ignore = [
51
+ "D105",
52
+ "D107",
53
+ ]
54
+
55
+ [tool.ruff.lint.pydocstyle]
56
+ convention = "google"
57
+
58
+ [tool.ruff.lint.per-file-ignores]
59
+ "tests/*" = ["D"]
60
+
61
+ [tool.semantic_release]
62
+ version_toml = ["pyproject.toml:project.version"]
63
+ build_command = ""
64
+ commit_message = "chore: release {version}"
65
+ commit_parser = "conventional"
66
+ tag_format = "v{version}"
67
+ changelog_file = false
68
+
69
+ [tool.semantic_release.branches.main]
70
+ match = "main"
71
+ prerelease = false
@@ -0,0 +1,64 @@
1
+ # Project metadata ------------------------------------------------------------
2
+ [project]
3
+ name = "markov-agent"
4
+ version = "1.4.0"
5
+ description = "A coding agent following SKILL.state paper principles."
6
+ requires-python = ">=3.13"
7
+ license = { text = "MIT" }
8
+ readme = "README.md"
9
+ authors = [{ name = "Nacho Llorca", email = "nllorca@proton.me" }]
10
+ dependencies = [
11
+ "lmdk>=2.10.3",
12
+ "textual>=6.0.0",
13
+ ]
14
+
15
+ [project.scripts]
16
+ markov = "markov_agent.tui:main"
17
+
18
+ # Dev / optional dependencies -------------------------------------------------
19
+ [dependency-groups]
20
+ dev = [
21
+ "logfire",
22
+ "complexipy",
23
+ "pytest-cov",
24
+ "ruff",
25
+ "ty",
26
+ ]
27
+
28
+ # Build backend ---------------------------------------------------------------
29
+ [build-system]
30
+ requires = ["uv_build>=0.11.1,<0.12.0"]
31
+ build-backend = "uv_build"
32
+
33
+ # Ruff (lint + format) --------------------------------------------------------
34
+ [tool.ruff]
35
+ fix = true
36
+ line-length = 100
37
+
38
+ [tool.ruff.lint]
39
+ select = ["D", "E", "F", "I", "UP", "B", "SIM", "RUF"]
40
+ ignore = ["D105", "D107"]
41
+
42
+ [tool.ruff.lint.pydocstyle]
43
+ convention = "google"
44
+
45
+ [tool.ruff.lint.per-file-ignores]
46
+ "tests/*" = ["D"]
47
+
48
+ # Semantic release (automated versioning from conventional commits) -----------
49
+ [tool.semantic_release]
50
+ version_toml = ["pyproject.toml:project.version"]
51
+ build_command = "" # We do this using uv in the CI verify.yaml workflow
52
+ commit_message = "chore: release {version}"
53
+ commit_parser = "conventional"
54
+ tag_format = "v{version}"
55
+ changelog_file = false
56
+
57
+ [tool.semantic_release.branches.main]
58
+ match = "main"
59
+ prerelease = false
60
+
61
+ # Spelling --------------------------------------------------------------------
62
+ # [tool.codespell]
63
+ # skip = ["", ""] # Comma separated list of regex strings to ignore files for the spelling check
64
+ # ignore-words-list = "firstword,secondword" # Specific strings to accept
@@ -0,0 +1 @@
1
+ """markov_agent."""
@@ -0,0 +1,35 @@
1
+ """Domain definition for a small coding agent on top of `skill_state.run`."""
2
+
3
+ from pydantic import BaseModel, Field
4
+
5
+ DEFAULT_MODEL = "mistral:mistral-large-4"
6
+
7
+ INSTRUCTIONS = """\
8
+ You are a coding agent: you search and edit files in a project, and prove the change works.
9
+
10
+ - Look before you change: read the relevant files (ls, grep, sed -n) before editing.
11
+ - Read narrowly: grep / sed -n over whole files, the output is the observation you pay for.
12
+ - Minimal diffs: sed / python one-liners / patches; never rewrite a whole file to change one line.
13
+ - Verified means executed: run the tests or the code itself and keep its output as proof.
14
+ """
15
+
16
+
17
+ class CodingAgentState(BaseModel):
18
+ """State of the coding agent."""
19
+
20
+ task: str = Field(description="The user's goal, restated in your words.")
21
+ plan: str = Field(
22
+ description="Remaining steps, next first. "
23
+ "A markdown task list: one `- [ ]` item per step, `- [X]` when done.",
24
+ )
25
+ facts: str = Field(
26
+ description="Everything learned from command output that you will need later: "
27
+ "file paths, error messages, line numbers, output values. As a markdown list."
28
+ )
29
+ failed_approaches: str = Field(description="Commands or fixes that did not work, and why.")
30
+ edits: str = Field(
31
+ description="Files changed so far, and why. A markdown list, one entry per line."
32
+ )
33
+ verification: str = Field(
34
+ description="Output of the run that proves the task is done.",
35
+ )
@@ -0,0 +1,48 @@
1
+ You are an agent running in a loop inside a Unix shell.
2
+ At each step you see only:
3
+ - The instructions below
4
+ - The request that started this session
5
+ - The current state
6
+ - Your last action, if there was one
7
+ - The latest observation, produced by your last action or by your reply to the user
8
+
9
+ Instructions:
10
+ ```
11
+ {{instructions}}
12
+ ```
13
+
14
+ Original Request:
15
+ ```
16
+ {{request}}
17
+ ```
18
+
19
+ Current execution state (step {{t}} of this turn):
20
+ ```json
21
+ {{state}}
22
+ ```
23
+ {% if last_action %}
24
+ Your last action:
25
+ ```bash
26
+ {{last_action}}
27
+ ```
28
+ {% endif %}
29
+ Latest observation:
30
+ ```
31
+ {{observation}}
32
+ ```
33
+
34
+ How to work:
35
+ - One action or one reply per step. Don't cram the whole task into one action: the loop will show its result.
36
+ - Both fields produce the next observation: a command's output, or the user's answer.
37
+ - The state is your only memory: the observation is never shown again and your reasoning is discarded, so anything you will need later must be in the state after this step.
38
+ - The observation wins over the state: if they disagree, the world changed or the state was wrong.
39
+ - Never redo work whose result is already in the state.
40
+ - Record failures, not just successes, so you don't retry what already failed.
41
+ - Every step must move the task forward: learn a missing fact, make a change, verify one, or ask the user.
42
+ - Only say the task is done when the state itself shows it is satisfied and verified.
43
+
44
+ How to update the state (the `patch` field only):
45
+ - Every state field appears in the patch, and each is required: set the ones you change to their new value, everything else to "unchanged" or "unset".
46
+ - Update: setting a field replaces its whole current value. To add to a field, rewrite it in full: its current content first, then what you are adding.
47
+ - Keep: set a field to "unchanged" to keep its current value.
48
+ - Delete: set a field to "unset" to clear it (uninitialized fields start as "unset").
@@ -0,0 +1,180 @@
1
+ """SKILL.state (arXiv:2608.26263) core implementation.
2
+
3
+ Prompt stays O(1) instead of growing with the conversation.
4
+ At each step t, the model sees only three things:
5
+ 1. P: The immutable skill spec
6
+ 2. Σₜ: The current state in the given per-domain structure
7
+ 3. Oₜ: The latest observation
8
+
9
+ It returns:
10
+ - A state update (patch) that produces Σₜ₊₁
11
+ - And either an action that will produce Oₜ₊₁, or a reply that ends the turn
12
+ and hands control back to the user
13
+ """
14
+
15
+ import json
16
+ import subprocess
17
+ from collections.abc import Iterator
18
+ from pathlib import Path
19
+ from typing import Any, Literal
20
+
21
+ from lmdk import complete, render_template
22
+ from pydantic import BaseModel, Field, create_model
23
+
24
+ PROMPT_TEMPLATE = Path(__file__).parent / "prompt_template.jinja" # ships with the package
25
+ BASH_TIMEOUT = 100 # seconds to wait for the bash
26
+ MAX_BASH_OUTPUT = 100_000 # maximum chars of observation shown to the model
27
+
28
+
29
+ def yolo_bash(command: str) -> str:
30
+ """Run a bash command and return its exit code, stdout and stderr."""
31
+ # ! Probably needs a white/black- list and / or a sandbox
32
+ proc = subprocess.run(command, shell=True, capture_output=True, text=True, timeout=BASH_TIMEOUT)
33
+ output = (proc.stdout + proc.stderr).strip() or "(no output)"
34
+ truncated = ""
35
+ if len(output) > MAX_BASH_OUTPUT:
36
+ truncated = f"\n[The output has been truncated at the maximum {MAX_BASH_OUTPUT} characters]"
37
+ return f"(exit {proc.returncode})\n{output[:MAX_BASH_OUTPUT]}{truncated}"
38
+
39
+
40
+ def update_state(state: dict, patch: dict) -> dict:
41
+ """Recursively merge *patch* (the state change made by the agent) into *state*."""
42
+ merged = dict(state)
43
+ for key, value in patch.items():
44
+ if value == "unchanged":
45
+ continue
46
+ if isinstance(value, dict) and isinstance(merged.get(key), dict):
47
+ merged[key] = update_state(merged[key], value)
48
+ else:
49
+ merged[key] = value
50
+ return merged
51
+
52
+
53
+ def derive_patch_schema(state_schema: type[BaseModel]) -> type[BaseModel]:
54
+ """Derive from the domain-defined schema a patch schema the model can emit.
55
+
56
+ Adds to each field the possibility to be "unchanged" (default) or "unset".
57
+ """
58
+ fields: dict[str, Any] = {}
59
+ for name, field in state_schema.model_fields.items():
60
+ ann = field.annotation
61
+ if isinstance(ann, type) and issubclass(ann, BaseModel):
62
+ ann = derive_patch_schema(ann)
63
+ patch_ann = ann | Literal["unchanged", "unset"]
64
+ fields[name] = (patch_ann, Field(default="unchanged", description=field.description))
65
+ return create_model(f"Patch_{state_schema.__name__}", **fields)
66
+
67
+
68
+ def derive_schema(state_schema: type[BaseModel]) -> type[BaseModel]:
69
+ """Combine the patch schema with the action-or-reply decision the model answers with.
70
+
71
+ The patch comes first so the model records what it learned before deciding what to do.
72
+ The decision is one `kind` plus one `content`, so strict decoding can only produce exactly
73
+ one of action or reply (two optional fields got the patch's "unchanged" or both filled).
74
+ """
75
+ return create_model(
76
+ f"Step{state_schema.__name__}",
77
+ __doc__="One step of the loop: mutate the state, then either act or hand the turn back.",
78
+ patch=(
79
+ derive_patch_schema(state_schema),
80
+ Field(description="State changes only, never `kind` or `content`."),
81
+ ),
82
+ kind=(
83
+ Literal["action", "reply"],
84
+ Field(
85
+ description="`action`: run a shell command yourself, when the step is yours to "
86
+ "execute. `reply`: hand the turn to the user, with the result, a question or a "
87
+ "blocker, when only they can decide or when the task is done."
88
+ ),
89
+ ),
90
+ content=(
91
+ str,
92
+ Field(
93
+ description="The shell command if `kind` is action, the text for the user if "
94
+ "reply. Its output, or the user's answer, becomes the next observation."
95
+ ),
96
+ ),
97
+ )
98
+
99
+
100
+ def initial_state(state_schema: type[BaseModel], state: dict | None = None) -> dict:
101
+ """Seed the state: required fields start as "unset", optional fields at their default."""
102
+ defaults = {
103
+ name: ("unset" if field.is_required() else field.get_default(call_default_factory=True))
104
+ for name, field in state_schema.model_fields.items()
105
+ }
106
+ return {**defaults, **(state or {})}
107
+
108
+
109
+ class StepEvent(BaseModel):
110
+ """Representation of model state, action and observation at time t."""
111
+
112
+ t: int
113
+ state: dict
114
+ patch: dict
115
+ action: str | None
116
+ reply: str | None = None
117
+ observation: str | None = None
118
+
119
+
120
+ def run(
121
+ model: str,
122
+ instructions: str,
123
+ request: str,
124
+ state_schema: type[BaseModel],
125
+ state: dict | None = None,
126
+ observation: str = "none",
127
+ max_steps: int = 50,
128
+ ) -> Iterator[StepEvent]:
129
+ """Execute one turn, until the model replies to the user or *max_steps* is hit.
130
+
131
+ Args:
132
+ model: Provider-prefixed model identifier, as in ``lmdk.complete``.
133
+ instructions: The immutable procedural specification (P in the paper).
134
+ state_schema: Domain-defined structure of the state (Σ in the paper) for this task.
135
+ request: The request that started the session. Stable across turns.
136
+ state: Initial execution state (Σ₀). Empty by default, can be used to start from checkpoint.
137
+ observation: The observation the turn starts from (O₀): the user's reply to the previous
138
+ turn, or "none" on the first one.
139
+ max_steps: Horizon cap, so a looping model cannot burn tokens forever.
140
+
141
+ Yields:
142
+ One ``StepEvent`` per step, carrying the post-patch state, the patch itself,
143
+ the action proposed and the observation it was conditioned on. The final
144
+ event carries the ``reply`` handed back to the user, or ``reply=None`` when
145
+ *max_steps* ran out. Call ``run`` again with the final state and the user's
146
+ next message to continue.
147
+ """
148
+ state = initial_state(state_schema, state)
149
+ schema = derive_schema(state_schema)
150
+ last_action = None # the model never sees its own previous action otherwise
151
+ for t in range(max_steps):
152
+ prompt = render_template(
153
+ path=PROMPT_TEMPLATE,
154
+ instructions=instructions,
155
+ request=request,
156
+ state=json.dumps(state, separators=(",", ":")),
157
+ observation=observation,
158
+ last_action=last_action,
159
+ t=t,
160
+ )
161
+ response = complete(
162
+ model=model, prompt=prompt, output_schema=schema, thinking_effort="high"
163
+ )
164
+ assert response.output is not None
165
+ step = response.output.model_dump()
166
+ patch = step["patch"]
167
+ state = update_state(state, patch)
168
+ replied = step["kind"] == "reply"
169
+ yield StepEvent(
170
+ t=t,
171
+ state=state,
172
+ patch=patch,
173
+ action=None if replied else step["content"],
174
+ reply=step["content"] if replied else None,
175
+ observation=observation,
176
+ )
177
+ if replied: # turn handed back to the user
178
+ break
179
+ last_action = step["content"]
180
+ observation = yolo_bash(last_action)
@@ -0,0 +1,238 @@
1
+ """Terminal UI for SKILL.state agents: watch the state get patched step by step.
2
+
3
+ The screen mirrors what the model sees at step t: the state Σₜ on the left, where each field
4
+ shows the diff the patch makes to produce Σₜ₊₁, and on the right the observation Oₜ plus the
5
+ action or reply the model answered with. A timeline scrubs through past steps and the input
6
+ opens the session (the request) and continues it (the reply to each of the agent's replies).
7
+ """
8
+
9
+ import argparse
10
+ import difflib
11
+ import json
12
+ import os
13
+ from typing import Any
14
+
15
+ from pydantic import BaseModel
16
+ from rich.text import Text
17
+ from textual import work
18
+ from textual.app import App, ComposeResult
19
+ from textual.binding import Binding
20
+ from textual.containers import Horizontal, Vertical, VerticalScroll
21
+ from textual.widgets import Collapsible, Footer, Header, Input, Static
22
+ from textual.worker import get_current_worker
23
+
24
+ from .agent import DEFAULT_MODEL, INSTRUCTIONS, CodingAgentState
25
+ from .skill_state import StepEvent, initial_state, run
26
+
27
+ _SUB = str.maketrans("0123456789", "₀₁₂₃₄₅₆₇₈₉")
28
+ _DIFF_STYLES = {"+": "green", "-": "red", " ": ""}
29
+
30
+
31
+ def _lines(value: Any) -> list[str]:
32
+ if value == "unset":
33
+ return []
34
+ return (value if isinstance(value, str) else json.dumps(value, indent=2)).splitlines()
35
+
36
+
37
+ def field_diff(old: Any, new: Any) -> Text:
38
+ """Line diff of one state field: removed lines red, added lines green."""
39
+ lines = difflib.ndiff(_lines(old), _lines(new))
40
+ # ndiff also emits "?" hint lines, which only make sense in monospace side by side
41
+ return Text("\n").join(Text(line, _DIFF_STYLES[line[0]]) for line in lines if line[0] != "?")
42
+
43
+
44
+ class MarkovApp(App):
45
+ """Run a SKILL.state agent one turn at a time, showing each step as state + patch."""
46
+
47
+ CSS = """
48
+ #main { height: 1fr; margin-top: 1; }
49
+ #state, #step { width: 1fr; }
50
+ #state { border: round $primary; }
51
+ #observation { height: 2fr; border: round $secondary; }
52
+ #cause { color: $accent; text-style: bold; border-bottom: solid $secondary; display: none; }
53
+ #output { height: 1fr; border: round $accent; }
54
+ #output.reply { border: heavy $success; }
55
+ Collapsible { padding-bottom: 0; }
56
+ Collapsible Contents { padding-top: 0; }
57
+ Collapsible.patched { border-left: thick $warning; }
58
+ #timeline { height: auto; max-height: 3; padding: 0 1; }
59
+ Input { margin-bottom: 1; }
60
+ """
61
+ BINDINGS = [ # noqa: RUF012 (textual's own convention)
62
+ # priority: pageup/pagedown must scrub even while typing in the input
63
+ Binding("pageup", "scrub(-1)", "Previous step", priority=True),
64
+ Binding("pagedown", "scrub(1)", "Next step", priority=True),
65
+ ]
66
+
67
+ def __init__(
68
+ self,
69
+ model: str,
70
+ instructions: str,
71
+ state_schema: type[BaseModel],
72
+ state: dict | None = None,
73
+ ):
74
+ super().__init__()
75
+ self.model = model
76
+ self.instructions = instructions
77
+ self.state_schema = state_schema
78
+ self.state = initial_state(state_schema, state)
79
+ self.request = ""
80
+ self.turn = 0
81
+ self.steps: list[tuple[int, dict, StepEvent]] = [] # (turn, state before the step, step)
82
+ self.selected = -1 # selected step; following live while it is the last one
83
+ self.status = "type your request below"
84
+
85
+ def compose(self) -> ComposeResult:
86
+ """Lay out a fixed panel per state field, so nothing moves between steps."""
87
+ yield Header()
88
+ with Horizontal(id="main"):
89
+ with VerticalScroll(id="state"):
90
+ with Collapsible(title="instructions (P)"):
91
+ yield Static(self.instructions, markup=False)
92
+ for name in self.state_schema.model_fields:
93
+ with Collapsible(title=name, id=f"field-{name}"):
94
+ yield Static(id=f"value-{name}", markup=False)
95
+ with Vertical(id="step"):
96
+ with Vertical(id="observation"):
97
+ yield Static(id="cause", markup=False) # the action whose output this is
98
+ with VerticalScroll():
99
+ yield Static(id="obs", markup=False)
100
+ with VerticalScroll(id="output"):
101
+ yield Static(id="out", markup=False)
102
+ yield Static(id="timeline")
103
+ yield Input(placeholder="Your request…")
104
+ yield Footer()
105
+
106
+ def on_mount(self) -> None:
107
+ """Show the initial state Σ₀ before anything ran."""
108
+ self.title = f"markov · {self.model}"
109
+ self._show_state(self.state, self.state, "STATE Σ₀")
110
+ self._show_timeline()
111
+ self.query_one(Input).focus()
112
+
113
+ def on_input_submitted(self, message: Input.Submitted) -> None:
114
+ """The first message is the request, every later one is the next turn's observation."""
115
+ text = message.value.strip()
116
+ if not text:
117
+ return
118
+ message.input.clear()
119
+ observation = text
120
+ if not self.request:
121
+ self.request, observation = text, "none"
122
+ self.sub_title = text
123
+ message.input.placeholder = "Your reply…"
124
+ self.turn += 1
125
+ self._set_busy("working…", busy=True)
126
+ self._run_turn(observation)
127
+
128
+ @work(thread=True, exclusive=True)
129
+ def _run_turn(self, observation: str) -> None:
130
+ """Consume `run` off the UI thread and hand every step to the UI as it arrives."""
131
+ worker = get_current_worker()
132
+ status = "step limit reached, reply to continue"
133
+ try:
134
+ for event in run(
135
+ model=self.model,
136
+ instructions=self.instructions,
137
+ request=self.request,
138
+ state_schema=self.state_schema,
139
+ state=self.state,
140
+ observation=observation,
141
+ ):
142
+ if worker.is_cancelled: # app is quitting
143
+ return
144
+ self.call_from_thread(self._add_step, event)
145
+ if event.reply:
146
+ status = "your turn"
147
+ except Exception as error: # keep the session: the state survives, the user can retry
148
+ status = f"error: {error}"
149
+ if not worker.is_cancelled:
150
+ self.call_from_thread(self._set_busy, status, busy=False)
151
+
152
+ def _add_step(self, event: StepEvent) -> None:
153
+ following = self.selected == len(self.steps) - 1
154
+ self.steps.append((self.turn, self.state, event))
155
+ self.state = event.state
156
+ if following:
157
+ self.selected = len(self.steps) - 1
158
+ self._show_step()
159
+
160
+ def _set_busy(self, status: str, busy: bool) -> None:
161
+ self.status = status
162
+ box = self.query_one(Input)
163
+ box.disabled = busy
164
+ if not busy:
165
+ box.focus()
166
+ self._show_timeline()
167
+
168
+ def action_scrub(self, delta: int) -> None:
169
+ """Move the selection through past steps; reaching the last one follows live again."""
170
+ if self.steps:
171
+ self.selected = max(0, min(len(self.steps) - 1, self.selected + delta))
172
+ self._show_step()
173
+
174
+ def _show_step(self) -> None:
175
+ _, before, event = self.steps[self.selected]
176
+ t, t1 = str(event.t).translate(_SUB), str(event.t + 1).translate(_SUB)
177
+ self._show_state(before, event.state, f"STATE Σ{t} → Σ{t1}")
178
+ # Oₜ is the output of the previous step's action, or the user's message at a turn start
179
+ cause = self.steps[self.selected - 1][2].action if event.t else None
180
+ source = f"a{str(event.t - 1).translate(_SUB)}" if cause else "user"
181
+ self.query_one("#observation").border_title = f"OBSERVATION O{t} ← {source}"
182
+ cause_box = self.query_one("#cause", Static)
183
+ cause_box.display = bool(cause)
184
+ cause_box.update(f"$ {cause}")
185
+ self.query_one("#obs", Static).update(event.observation or "none")
186
+ output = self.query_one("#output")
187
+ output.set_class(bool(event.reply), "reply")
188
+ output.border_title = "REPLY" if event.reply else f"ACTION a{t}"
189
+ self.query_one("#out", Static).update(event.reply or event.action or "")
190
+ self._show_timeline()
191
+
192
+ def _show_state(self, before: dict, after: dict, title: str) -> None:
193
+ self.query_one("#state").border_title = title
194
+ for name in self.state_schema.model_fields:
195
+ old, new = before.get(name), after.get(name)
196
+ changed = old != new
197
+ label = "patched" if changed else "unset" if new == "unset" else "unchanged"
198
+ box = self.query_one(f"#field-{name}", Collapsible)
199
+ box.title = f"{name} · {label}"
200
+ box.collapsed = not changed
201
+ box.set_class(changed, "patched")
202
+ value = field_diff(old, new) if changed else Text("\n".join(_lines(new)), "dim")
203
+ self.query_one(f"#value-{name}", Static).update(value)
204
+
205
+ def _show_timeline(self) -> None:
206
+ # shortcut: one glyph per step wraps after ~3 lines of steps, upgrade to a scrolling strip
207
+ line = Text()
208
+ for i, (_, _, event) in enumerate(self.steps):
209
+ if event.t == 0 and i:
210
+ line.append("│", "dim")
211
+ line.append("◉" if i == self.selected else "○", "bold" if i == self.selected else "dim")
212
+ if self.steps:
213
+ turn, _, event = self.steps[self.selected]
214
+ line.append(f" turn {turn} · step {event.t}")
215
+ line.append(f" {self.status}", "italic")
216
+ self.query_one("#timeline", Static).update(line)
217
+
218
+
219
+ def main() -> None:
220
+ """Entry point of the ``markov`` command."""
221
+ parser = argparse.ArgumentParser(description="Run the coding agent in a terminal UI.")
222
+ parser.add_argument("--model", default=DEFAULT_MODEL, help="Language model to use.")
223
+ parser.add_argument("--working-dir", default=os.getcwd(), help="Project directory.")
224
+ args = parser.parse_args()
225
+
226
+ if os.getenv("LMDK_TELEMETRY"):
227
+ import logfire
228
+
229
+ logfire.configure(
230
+ token=os.environ["LOGFIRE_TOKEN"],
231
+ service_name="skill-state-coding-agent",
232
+ scrubbing=False,
233
+ send_to_logfire=True,
234
+ console=False, # console output would draw over the TUI
235
+ )
236
+
237
+ os.chdir(args.working_dir) # commands run in a shell that inherits this cwd
238
+ MarkovApp(args.model, INSTRUCTIONS, CodingAgentState).run()