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.
- markov_agent-1.4.0/PKG-INFO +76 -0
- markov_agent-1.4.0/README.md +64 -0
- markov_agent-1.4.0/pyproject.toml +71 -0
- markov_agent-1.4.0/pyproject.toml.orig +64 -0
- markov_agent-1.4.0/src/markov_agent/__init__.py +1 -0
- markov_agent-1.4.0/src/markov_agent/agent.py +35 -0
- markov_agent-1.4.0/src/markov_agent/prompt_template.jinja +48 -0
- markov_agent-1.4.0/src/markov_agent/skill_state.py +180 -0
- markov_agent-1.4.0/src/markov_agent/tui.py +238 -0
|
@@ -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
|
+

|
|
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
|
+

|
|
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()
|