langchain-task-steering 0.1.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.
- langchain_task_steering-0.1.0/.github/workflows/publish.yml +27 -0
- langchain_task_steering-0.1.0/.gitignore +33 -0
- langchain_task_steering-0.1.0/LICENSE +21 -0
- langchain_task_steering-0.1.0/PKG-INFO +310 -0
- langchain_task_steering-0.1.0/README.md +283 -0
- langchain_task_steering-0.1.0/examples/simple_agent.py +116 -0
- langchain_task_steering-0.1.0/pyproject.toml +46 -0
- langchain_task_steering-0.1.0/src/task_steering/__init__.py +10 -0
- langchain_task_steering-0.1.0/src/task_steering/middleware.py +374 -0
- langchain_task_steering-0.1.0/src/task_steering/py.typed +0 -0
- langchain_task_steering-0.1.0/src/task_steering/types.py +89 -0
- langchain_task_steering-0.1.0/tests/__init__.py +0 -0
- langchain_task_steering-0.1.0/tests/conftest.py +159 -0
- langchain_task_steering-0.1.0/tests/test_middleware.py +928 -0
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
name: Publish to Pypi
|
|
2
|
+
|
|
3
|
+
on:
|
|
4
|
+
release:
|
|
5
|
+
types: [published]
|
|
6
|
+
|
|
7
|
+
permissions:
|
|
8
|
+
id-token: write
|
|
9
|
+
|
|
10
|
+
jobs:
|
|
11
|
+
publish:
|
|
12
|
+
runs-on: ubuntu-latest
|
|
13
|
+
environment: pypi
|
|
14
|
+
steps:
|
|
15
|
+
- uses: actions/checkout@v4
|
|
16
|
+
|
|
17
|
+
- uses: actions/setup-python@v5
|
|
18
|
+
with:
|
|
19
|
+
python-version: "3.12"
|
|
20
|
+
|
|
21
|
+
- name: Build package
|
|
22
|
+
run: |
|
|
23
|
+
pip install build
|
|
24
|
+
python -m build
|
|
25
|
+
|
|
26
|
+
- name: Publish to Pypi
|
|
27
|
+
uses: pypa/gh-action-pypi-publish@release/v1
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
# Python
|
|
2
|
+
__pycache__/
|
|
3
|
+
*.py[cod]
|
|
4
|
+
*$py.class
|
|
5
|
+
*.egg-info/
|
|
6
|
+
dist/
|
|
7
|
+
build/
|
|
8
|
+
*.egg
|
|
9
|
+
|
|
10
|
+
# Virtual environments
|
|
11
|
+
.venv/
|
|
12
|
+
venv/
|
|
13
|
+
env/
|
|
14
|
+
|
|
15
|
+
# Testing
|
|
16
|
+
.pytest_cache/
|
|
17
|
+
.coverage
|
|
18
|
+
htmlcov/
|
|
19
|
+
|
|
20
|
+
# IDE
|
|
21
|
+
.idea/
|
|
22
|
+
.vscode/
|
|
23
|
+
*.swp
|
|
24
|
+
*.swo
|
|
25
|
+
|
|
26
|
+
# OS
|
|
27
|
+
.DS_Store
|
|
28
|
+
Thumbs.db
|
|
29
|
+
|
|
30
|
+
# Archives
|
|
31
|
+
*.zip
|
|
32
|
+
|
|
33
|
+
dist/
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2025 task-steering contributors
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
|
@@ -0,0 +1,310 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: langchain-task-steering
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Implicit state machine middleware for LangChain v1 agents. Ordered task pipelines with per-task tool scoping, prompt injection, and composable validation.
|
|
5
|
+
Project-URL: Homepage, https://github.com/edvinhallvaxhiu/langchain-task-steering
|
|
6
|
+
Project-URL: Repository, https://github.com/edvinhallvaxhiu/langchain-task-steering
|
|
7
|
+
Project-URL: Issues, https://github.com/edvinhallvaxhiu/langchain-task-steering/issues
|
|
8
|
+
Author: Edvin Hallvaxhiu
|
|
9
|
+
License-Expression: MIT
|
|
10
|
+
License-File: LICENSE
|
|
11
|
+
Keywords: agents,langchain,langgraph,middleware,state-machine,task-pipeline
|
|
12
|
+
Classifier: Development Status :: 3 - Alpha
|
|
13
|
+
Classifier: Intended Audience :: Developers
|
|
14
|
+
Classifier: License :: OSI Approved :: MIT License
|
|
15
|
+
Classifier: Programming Language :: Python :: 3
|
|
16
|
+
Classifier: Programming Language :: Python :: 3.10
|
|
17
|
+
Classifier: Programming Language :: Python :: 3.11
|
|
18
|
+
Classifier: Programming Language :: Python :: 3.12
|
|
19
|
+
Classifier: Topic :: Software Development :: Libraries
|
|
20
|
+
Requires-Python: >=3.10
|
|
21
|
+
Requires-Dist: langchain>=1.0.0
|
|
22
|
+
Requires-Dist: langgraph>=0.4.0
|
|
23
|
+
Provides-Extra: dev
|
|
24
|
+
Requires-Dist: pytest-cov; extra == 'dev'
|
|
25
|
+
Requires-Dist: pytest>=8.0; extra == 'dev'
|
|
26
|
+
Description-Content-Type: text/markdown
|
|
27
|
+
|
|
28
|
+
# task-steering
|
|
29
|
+
|
|
30
|
+
Implicit state-machine middleware for [LangChain v1](https://python.langchain.com/) agents. Define ordered task pipelines with per-task tool scoping, dynamic prompt injection, and composable validation — all as a drop-in `AgentMiddleware`.
|
|
31
|
+
|
|
32
|
+
```
|
|
33
|
+
PENDING ──> IN_PROGRESS ──> COMPLETE
|
|
34
|
+
```
|
|
35
|
+
|
|
36
|
+
The model drives its own transitions by calling `update_task_status`. The middleware enforces ordering, scopes tools, injects the active task's instruction into the system prompt, and gates completion via pluggable validators.
|
|
37
|
+
|
|
38
|
+
## When to use this
|
|
39
|
+
|
|
40
|
+
| Scenario | task-steering | LangGraph explicit workflows |
|
|
41
|
+
|---|:---:|:---:|
|
|
42
|
+
| Linear task pipeline (A then B then C) | **Best fit** | Verbose — one node + edges per task |
|
|
43
|
+
| Per-task tool scoping | **Built-in** | Manual — separate tool lists per node |
|
|
44
|
+
| Dynamic tasks from config / DB | **Easy** — tasks are data | Hard — graph is compiled at build time |
|
|
45
|
+
| Branching / parallel execution | Not supported | **Built-in** — edges + `Send()` |
|
|
46
|
+
| Per-task human-in-the-loop interrupts | Not supported | **Built-in** — `interrupt()` per node |
|
|
47
|
+
| Complex orchestration with retries / cycles | Not supported | **Built-in** — conditional edges |
|
|
48
|
+
| Composition with other middleware | **Native** — it's an `AgentMiddleware` | N/A — different abstraction |
|
|
49
|
+
| Debuggability in LangGraph Studio | Opaque — single agent node | **Clear** — each node visible in traces |
|
|
50
|
+
|
|
51
|
+
**Rule of thumb:** If your tasks are sequential and tool-scoped, use task-steering. If you need branching, parallelism, or per-task interrupts, use explicit LangGraph workflows.
|
|
52
|
+
|
|
53
|
+
## Install
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
pip install task-steering
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
For development:
|
|
60
|
+
|
|
61
|
+
```bash
|
|
62
|
+
git clone https://github.com/edvinhallvaxhiu/task-steering
|
|
63
|
+
cd task-steering
|
|
64
|
+
pip install -e ".[dev]"
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
### Requirements
|
|
68
|
+
|
|
69
|
+
- Python >= 3.10
|
|
70
|
+
- `langchain >= 1.0.0`
|
|
71
|
+
- `langgraph >= 0.4.0`
|
|
72
|
+
|
|
73
|
+
## Quick start
|
|
74
|
+
|
|
75
|
+
```python
|
|
76
|
+
from langchain.agents import create_agent
|
|
77
|
+
from langchain.tools import tool
|
|
78
|
+
from task_steering import TaskSteeringMiddleware, Task
|
|
79
|
+
|
|
80
|
+
|
|
81
|
+
@tool
|
|
82
|
+
def add_items(items: list[str]) -> str:
|
|
83
|
+
"""Add items to the inventory."""
|
|
84
|
+
return f"Added {len(items)} items."
|
|
85
|
+
|
|
86
|
+
|
|
87
|
+
@tool
|
|
88
|
+
def categorize(categories: dict[str, list[str]]) -> str:
|
|
89
|
+
"""Assign items to categories."""
|
|
90
|
+
return f"Categorized into {len(categories)} groups."
|
|
91
|
+
|
|
92
|
+
|
|
93
|
+
pipeline = TaskSteeringMiddleware(
|
|
94
|
+
tasks=[
|
|
95
|
+
Task(
|
|
96
|
+
name="collect",
|
|
97
|
+
instruction="Collect all relevant items from the user's input.",
|
|
98
|
+
tools=[add_items],
|
|
99
|
+
),
|
|
100
|
+
Task(
|
|
101
|
+
name="categorize",
|
|
102
|
+
instruction="Organize the collected items into categories.",
|
|
103
|
+
tools=[categorize],
|
|
104
|
+
),
|
|
105
|
+
],
|
|
106
|
+
)
|
|
107
|
+
|
|
108
|
+
agent = create_agent(
|
|
109
|
+
model="anthropic:claude-sonnet-4-6",
|
|
110
|
+
middleware=[pipeline],
|
|
111
|
+
system_prompt="You are an inventory assistant.",
|
|
112
|
+
)
|
|
113
|
+
|
|
114
|
+
result = agent.invoke(
|
|
115
|
+
{"messages": [{"role": "user", "content": "I have apples, bolts, and milk."}]}
|
|
116
|
+
)
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
The agent automatically receives an `update_task_status` tool and sees a task pipeline block in its system prompt. It must complete `collect` before starting `categorize`.
|
|
120
|
+
|
|
121
|
+
## How it works
|
|
122
|
+
|
|
123
|
+
### What the model sees
|
|
124
|
+
|
|
125
|
+
Every model call, the middleware appends a status block to the system prompt:
|
|
126
|
+
|
|
127
|
+
```xml
|
|
128
|
+
<task_pipeline>
|
|
129
|
+
[x] collect (complete)
|
|
130
|
+
[>] categorize (in_progress)
|
|
131
|
+
|
|
132
|
+
<current_task name="categorize">
|
|
133
|
+
Organize the collected items into categories.
|
|
134
|
+
</current_task>
|
|
135
|
+
|
|
136
|
+
<rules>
|
|
137
|
+
Required order: collect -> categorize
|
|
138
|
+
Use update_task_status to advance. Do not skip tasks.
|
|
139
|
+
</rules>
|
|
140
|
+
</task_pipeline>
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
Only the active task's tools (plus globals and `update_task_status`) are visible to the model.
|
|
144
|
+
|
|
145
|
+
### Middleware hooks
|
|
146
|
+
|
|
147
|
+
| Hook | Behavior |
|
|
148
|
+
|---|---|
|
|
149
|
+
| `before_agent` | Initializes `task_statuses` in state on first invocation. |
|
|
150
|
+
| `wrap_model_call` | Appends task status board + active task instruction to system prompt. Filters tools to only the active task's tools + globals + `update_task_status`. Delegates to task-scoped middleware if present. |
|
|
151
|
+
| `wrap_tool_call` | Intercepts `update_task_status` — runs `validate_completion` on the task's scoped middleware before allowing completion. Rejects out-of-scope tool calls. Delegates other tool calls to the active task's scoped middleware. |
|
|
152
|
+
| `tools` | Auto-registers all task tools + globals + `update_task_status` with the agent. |
|
|
153
|
+
|
|
154
|
+
### Task lifecycle
|
|
155
|
+
|
|
156
|
+
```
|
|
157
|
+
PENDING ──> IN_PROGRESS ──> COMPLETE
|
|
158
|
+
```
|
|
159
|
+
|
|
160
|
+
- The agent drives transitions by calling `update_task_status(task, status)`.
|
|
161
|
+
- Transitions are enforced: `pending -> in_progress -> complete` only.
|
|
162
|
+
- When `enforce_order=True`, a task cannot start until all preceding tasks are complete.
|
|
163
|
+
- On `complete`, the task's `middleware.validate_completion(state)` runs first — rejection returns an error to the agent without completing the transition.
|
|
164
|
+
|
|
165
|
+
## Task-scoped middleware
|
|
166
|
+
|
|
167
|
+
Each task can have a `TaskMiddleware` that activates only when the task is `IN_PROGRESS`. This enables mid-task enforcement, not just completion gating.
|
|
168
|
+
|
|
169
|
+
```python
|
|
170
|
+
from langchain.messages import ToolMessage
|
|
171
|
+
from task_steering import Task, TaskMiddleware, TaskSteeringMiddleware
|
|
172
|
+
|
|
173
|
+
|
|
174
|
+
class ThreatsMiddleware(TaskMiddleware):
|
|
175
|
+
"""Block gap_analysis until enough threats exist."""
|
|
176
|
+
|
|
177
|
+
def __init__(self, min_threats: int = 25):
|
|
178
|
+
super().__init__()
|
|
179
|
+
self.min_threats = min_threats
|
|
180
|
+
|
|
181
|
+
def validate_completion(self, state) -> str | None:
|
|
182
|
+
threats = state.get("threats", [])
|
|
183
|
+
if len(threats) < self.min_threats:
|
|
184
|
+
return f"Only {len(threats)} threats — need at least {self.min_threats}."
|
|
185
|
+
return None
|
|
186
|
+
|
|
187
|
+
def wrap_tool_call(self, request, handler):
|
|
188
|
+
if request.tool_call["name"] == "gap_analysis":
|
|
189
|
+
threats = request.state.get("threats", [])
|
|
190
|
+
if len(threats) < self.min_threats:
|
|
191
|
+
return ToolMessage(
|
|
192
|
+
content=f"Cannot run gap_analysis: {len(threats)}/{self.min_threats} threats.",
|
|
193
|
+
tool_call_id=request.tool_call["id"],
|
|
194
|
+
)
|
|
195
|
+
return handler(request)
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
pipeline = TaskSteeringMiddleware(
|
|
199
|
+
tasks=[
|
|
200
|
+
Task(name="assets", instruction="...", tools=[create_assets]),
|
|
201
|
+
Task(
|
|
202
|
+
name="threats",
|
|
203
|
+
instruction="Identify STRIDE threats for each asset.",
|
|
204
|
+
tools=[create_threats, gap_analysis],
|
|
205
|
+
middleware=ThreatsMiddleware(min_threats=25),
|
|
206
|
+
),
|
|
207
|
+
],
|
|
208
|
+
)
|
|
209
|
+
```
|
|
210
|
+
|
|
211
|
+
### TaskMiddleware hooks
|
|
212
|
+
|
|
213
|
+
| Method | When it runs | Purpose |
|
|
214
|
+
|---|---|---|
|
|
215
|
+
| `validate_completion(state)` | Before `complete` transition | Return error string to reject, `None` to allow |
|
|
216
|
+
| `on_start(state)` | After successful `in_progress` transition | Side effects (logging, state init) |
|
|
217
|
+
| `on_complete(state)` | After successful `complete` transition | Side effects (trail capture, cleanup) |
|
|
218
|
+
| `wrap_tool_call(request, handler)` | On every tool call during this task | Mid-task tool gating / modification |
|
|
219
|
+
| `wrap_model_call(request, handler)` | On every model call during this task | Extra prompt injection / request modification |
|
|
220
|
+
| `state_schema` | At middleware init | Merge custom state fields into the agent's state |
|
|
221
|
+
|
|
222
|
+
### Persistent state for task middleware
|
|
223
|
+
|
|
224
|
+
Task middleware can declare a `state_schema` to persist custom fields across interrupts:
|
|
225
|
+
|
|
226
|
+
```python
|
|
227
|
+
from langchain.agents import AgentState
|
|
228
|
+
from typing_extensions import NotRequired
|
|
229
|
+
|
|
230
|
+
|
|
231
|
+
class ThreatsState(AgentState):
|
|
232
|
+
gap_analysis_uses: NotRequired[int]
|
|
233
|
+
|
|
234
|
+
|
|
235
|
+
class ThreatsMiddleware(TaskMiddleware):
|
|
236
|
+
state_schema = ThreatsState
|
|
237
|
+
# ...
|
|
238
|
+
```
|
|
239
|
+
|
|
240
|
+
`TaskSteeringMiddleware` automatically merges all task middleware schemas into its own `state_schema`, so the fields survive checkpointing and interrupts.
|
|
241
|
+
|
|
242
|
+
## Configuration
|
|
243
|
+
|
|
244
|
+
| Parameter | Default | Description |
|
|
245
|
+
|---|---|---|
|
|
246
|
+
| `tasks` | *(required)* | Ordered list of `Task` definitions. |
|
|
247
|
+
| `global_tools` | `[]` | Tools available in every task. |
|
|
248
|
+
| `enforce_order` | `True` | Require tasks to be completed in definition order. |
|
|
249
|
+
|
|
250
|
+
### Task fields
|
|
251
|
+
|
|
252
|
+
| Field | Required | Description |
|
|
253
|
+
|---|---|---|
|
|
254
|
+
| `name` | yes | Unique identifier (used in prompts and state). |
|
|
255
|
+
| `instruction` | yes | Injected into system prompt when this task is active. |
|
|
256
|
+
| `tools` | yes | Tools visible when this task is `IN_PROGRESS`. |
|
|
257
|
+
| `middleware` | no | Scoped `TaskMiddleware` — only active during this task. |
|
|
258
|
+
|
|
259
|
+
## Composability
|
|
260
|
+
|
|
261
|
+
`TaskSteeringMiddleware` is a standard `AgentMiddleware`. It composes with other LangChain v1 middleware:
|
|
262
|
+
|
|
263
|
+
```python
|
|
264
|
+
from langchain.agents import create_agent
|
|
265
|
+
from langchain.agents.middleware import SummarizationMiddleware
|
|
266
|
+
|
|
267
|
+
agent = create_agent(
|
|
268
|
+
model="anthropic:claude-sonnet-4-6",
|
|
269
|
+
middleware=[
|
|
270
|
+
SummarizationMiddleware(
|
|
271
|
+
model="anthropic:claude-haiku-4-5-20251001",
|
|
272
|
+
trigger={"tokens": 8000},
|
|
273
|
+
),
|
|
274
|
+
pipeline,
|
|
275
|
+
],
|
|
276
|
+
)
|
|
277
|
+
```
|
|
278
|
+
|
|
279
|
+
## Development
|
|
280
|
+
|
|
281
|
+
```bash
|
|
282
|
+
# Install with dev dependencies
|
|
283
|
+
pip install -e ".[dev]"
|
|
284
|
+
|
|
285
|
+
# Run tests
|
|
286
|
+
pytest
|
|
287
|
+
|
|
288
|
+
# Run tests with coverage
|
|
289
|
+
pytest --cov=task_steering
|
|
290
|
+
```
|
|
291
|
+
|
|
292
|
+
## Project structure
|
|
293
|
+
|
|
294
|
+
```
|
|
295
|
+
task-steering/
|
|
296
|
+
src/task_steering/
|
|
297
|
+
__init__.py # Public exports
|
|
298
|
+
types.py # Task, TaskMiddleware, TaskStatus, TaskSteeringState
|
|
299
|
+
middleware.py # TaskSteeringMiddleware implementation
|
|
300
|
+
tests/
|
|
301
|
+
conftest.py # Fixtures and mock objects
|
|
302
|
+
test_middleware.py # Test suite
|
|
303
|
+
examples/
|
|
304
|
+
simple_agent.py # End-to-end example with Bedrock
|
|
305
|
+
pyproject.toml
|
|
306
|
+
```
|
|
307
|
+
|
|
308
|
+
## License
|
|
309
|
+
|
|
310
|
+
MIT — see [LICENSE](LICENSE).
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
# task-steering
|
|
2
|
+
|
|
3
|
+
Implicit state-machine middleware for [LangChain v1](https://python.langchain.com/) agents. Define ordered task pipelines with per-task tool scoping, dynamic prompt injection, and composable validation — all as a drop-in `AgentMiddleware`.
|
|
4
|
+
|
|
5
|
+
```
|
|
6
|
+
PENDING ──> IN_PROGRESS ──> COMPLETE
|
|
7
|
+
```
|
|
8
|
+
|
|
9
|
+
The model drives its own transitions by calling `update_task_status`. The middleware enforces ordering, scopes tools, injects the active task's instruction into the system prompt, and gates completion via pluggable validators.
|
|
10
|
+
|
|
11
|
+
## When to use this
|
|
12
|
+
|
|
13
|
+
| Scenario | task-steering | LangGraph explicit workflows |
|
|
14
|
+
|---|:---:|:---:|
|
|
15
|
+
| Linear task pipeline (A then B then C) | **Best fit** | Verbose — one node + edges per task |
|
|
16
|
+
| Per-task tool scoping | **Built-in** | Manual — separate tool lists per node |
|
|
17
|
+
| Dynamic tasks from config / DB | **Easy** — tasks are data | Hard — graph is compiled at build time |
|
|
18
|
+
| Branching / parallel execution | Not supported | **Built-in** — edges + `Send()` |
|
|
19
|
+
| Per-task human-in-the-loop interrupts | Not supported | **Built-in** — `interrupt()` per node |
|
|
20
|
+
| Complex orchestration with retries / cycles | Not supported | **Built-in** — conditional edges |
|
|
21
|
+
| Composition with other middleware | **Native** — it's an `AgentMiddleware` | N/A — different abstraction |
|
|
22
|
+
| Debuggability in LangGraph Studio | Opaque — single agent node | **Clear** — each node visible in traces |
|
|
23
|
+
|
|
24
|
+
**Rule of thumb:** If your tasks are sequential and tool-scoped, use task-steering. If you need branching, parallelism, or per-task interrupts, use explicit LangGraph workflows.
|
|
25
|
+
|
|
26
|
+
## Install
|
|
27
|
+
|
|
28
|
+
```bash
|
|
29
|
+
pip install task-steering
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
For development:
|
|
33
|
+
|
|
34
|
+
```bash
|
|
35
|
+
git clone https://github.com/edvinhallvaxhiu/task-steering
|
|
36
|
+
cd task-steering
|
|
37
|
+
pip install -e ".[dev]"
|
|
38
|
+
```
|
|
39
|
+
|
|
40
|
+
### Requirements
|
|
41
|
+
|
|
42
|
+
- Python >= 3.10
|
|
43
|
+
- `langchain >= 1.0.0`
|
|
44
|
+
- `langgraph >= 0.4.0`
|
|
45
|
+
|
|
46
|
+
## Quick start
|
|
47
|
+
|
|
48
|
+
```python
|
|
49
|
+
from langchain.agents import create_agent
|
|
50
|
+
from langchain.tools import tool
|
|
51
|
+
from task_steering import TaskSteeringMiddleware, Task
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@tool
|
|
55
|
+
def add_items(items: list[str]) -> str:
|
|
56
|
+
"""Add items to the inventory."""
|
|
57
|
+
return f"Added {len(items)} items."
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
@tool
|
|
61
|
+
def categorize(categories: dict[str, list[str]]) -> str:
|
|
62
|
+
"""Assign items to categories."""
|
|
63
|
+
return f"Categorized into {len(categories)} groups."
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
pipeline = TaskSteeringMiddleware(
|
|
67
|
+
tasks=[
|
|
68
|
+
Task(
|
|
69
|
+
name="collect",
|
|
70
|
+
instruction="Collect all relevant items from the user's input.",
|
|
71
|
+
tools=[add_items],
|
|
72
|
+
),
|
|
73
|
+
Task(
|
|
74
|
+
name="categorize",
|
|
75
|
+
instruction="Organize the collected items into categories.",
|
|
76
|
+
tools=[categorize],
|
|
77
|
+
),
|
|
78
|
+
],
|
|
79
|
+
)
|
|
80
|
+
|
|
81
|
+
agent = create_agent(
|
|
82
|
+
model="anthropic:claude-sonnet-4-6",
|
|
83
|
+
middleware=[pipeline],
|
|
84
|
+
system_prompt="You are an inventory assistant.",
|
|
85
|
+
)
|
|
86
|
+
|
|
87
|
+
result = agent.invoke(
|
|
88
|
+
{"messages": [{"role": "user", "content": "I have apples, bolts, and milk."}]}
|
|
89
|
+
)
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
The agent automatically receives an `update_task_status` tool and sees a task pipeline block in its system prompt. It must complete `collect` before starting `categorize`.
|
|
93
|
+
|
|
94
|
+
## How it works
|
|
95
|
+
|
|
96
|
+
### What the model sees
|
|
97
|
+
|
|
98
|
+
Every model call, the middleware appends a status block to the system prompt:
|
|
99
|
+
|
|
100
|
+
```xml
|
|
101
|
+
<task_pipeline>
|
|
102
|
+
[x] collect (complete)
|
|
103
|
+
[>] categorize (in_progress)
|
|
104
|
+
|
|
105
|
+
<current_task name="categorize">
|
|
106
|
+
Organize the collected items into categories.
|
|
107
|
+
</current_task>
|
|
108
|
+
|
|
109
|
+
<rules>
|
|
110
|
+
Required order: collect -> categorize
|
|
111
|
+
Use update_task_status to advance. Do not skip tasks.
|
|
112
|
+
</rules>
|
|
113
|
+
</task_pipeline>
|
|
114
|
+
```
|
|
115
|
+
|
|
116
|
+
Only the active task's tools (plus globals and `update_task_status`) are visible to the model.
|
|
117
|
+
|
|
118
|
+
### Middleware hooks
|
|
119
|
+
|
|
120
|
+
| Hook | Behavior |
|
|
121
|
+
|---|---|
|
|
122
|
+
| `before_agent` | Initializes `task_statuses` in state on first invocation. |
|
|
123
|
+
| `wrap_model_call` | Appends task status board + active task instruction to system prompt. Filters tools to only the active task's tools + globals + `update_task_status`. Delegates to task-scoped middleware if present. |
|
|
124
|
+
| `wrap_tool_call` | Intercepts `update_task_status` — runs `validate_completion` on the task's scoped middleware before allowing completion. Rejects out-of-scope tool calls. Delegates other tool calls to the active task's scoped middleware. |
|
|
125
|
+
| `tools` | Auto-registers all task tools + globals + `update_task_status` with the agent. |
|
|
126
|
+
|
|
127
|
+
### Task lifecycle
|
|
128
|
+
|
|
129
|
+
```
|
|
130
|
+
PENDING ──> IN_PROGRESS ──> COMPLETE
|
|
131
|
+
```
|
|
132
|
+
|
|
133
|
+
- The agent drives transitions by calling `update_task_status(task, status)`.
|
|
134
|
+
- Transitions are enforced: `pending -> in_progress -> complete` only.
|
|
135
|
+
- When `enforce_order=True`, a task cannot start until all preceding tasks are complete.
|
|
136
|
+
- On `complete`, the task's `middleware.validate_completion(state)` runs first — rejection returns an error to the agent without completing the transition.
|
|
137
|
+
|
|
138
|
+
## Task-scoped middleware
|
|
139
|
+
|
|
140
|
+
Each task can have a `TaskMiddleware` that activates only when the task is `IN_PROGRESS`. This enables mid-task enforcement, not just completion gating.
|
|
141
|
+
|
|
142
|
+
```python
|
|
143
|
+
from langchain.messages import ToolMessage
|
|
144
|
+
from task_steering import Task, TaskMiddleware, TaskSteeringMiddleware
|
|
145
|
+
|
|
146
|
+
|
|
147
|
+
class ThreatsMiddleware(TaskMiddleware):
|
|
148
|
+
"""Block gap_analysis until enough threats exist."""
|
|
149
|
+
|
|
150
|
+
def __init__(self, min_threats: int = 25):
|
|
151
|
+
super().__init__()
|
|
152
|
+
self.min_threats = min_threats
|
|
153
|
+
|
|
154
|
+
def validate_completion(self, state) -> str | None:
|
|
155
|
+
threats = state.get("threats", [])
|
|
156
|
+
if len(threats) < self.min_threats:
|
|
157
|
+
return f"Only {len(threats)} threats — need at least {self.min_threats}."
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
def wrap_tool_call(self, request, handler):
|
|
161
|
+
if request.tool_call["name"] == "gap_analysis":
|
|
162
|
+
threats = request.state.get("threats", [])
|
|
163
|
+
if len(threats) < self.min_threats:
|
|
164
|
+
return ToolMessage(
|
|
165
|
+
content=f"Cannot run gap_analysis: {len(threats)}/{self.min_threats} threats.",
|
|
166
|
+
tool_call_id=request.tool_call["id"],
|
|
167
|
+
)
|
|
168
|
+
return handler(request)
|
|
169
|
+
|
|
170
|
+
|
|
171
|
+
pipeline = TaskSteeringMiddleware(
|
|
172
|
+
tasks=[
|
|
173
|
+
Task(name="assets", instruction="...", tools=[create_assets]),
|
|
174
|
+
Task(
|
|
175
|
+
name="threats",
|
|
176
|
+
instruction="Identify STRIDE threats for each asset.",
|
|
177
|
+
tools=[create_threats, gap_analysis],
|
|
178
|
+
middleware=ThreatsMiddleware(min_threats=25),
|
|
179
|
+
),
|
|
180
|
+
],
|
|
181
|
+
)
|
|
182
|
+
```
|
|
183
|
+
|
|
184
|
+
### TaskMiddleware hooks
|
|
185
|
+
|
|
186
|
+
| Method | When it runs | Purpose |
|
|
187
|
+
|---|---|---|
|
|
188
|
+
| `validate_completion(state)` | Before `complete` transition | Return error string to reject, `None` to allow |
|
|
189
|
+
| `on_start(state)` | After successful `in_progress` transition | Side effects (logging, state init) |
|
|
190
|
+
| `on_complete(state)` | After successful `complete` transition | Side effects (trail capture, cleanup) |
|
|
191
|
+
| `wrap_tool_call(request, handler)` | On every tool call during this task | Mid-task tool gating / modification |
|
|
192
|
+
| `wrap_model_call(request, handler)` | On every model call during this task | Extra prompt injection / request modification |
|
|
193
|
+
| `state_schema` | At middleware init | Merge custom state fields into the agent's state |
|
|
194
|
+
|
|
195
|
+
### Persistent state for task middleware
|
|
196
|
+
|
|
197
|
+
Task middleware can declare a `state_schema` to persist custom fields across interrupts:
|
|
198
|
+
|
|
199
|
+
```python
|
|
200
|
+
from langchain.agents import AgentState
|
|
201
|
+
from typing_extensions import NotRequired
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
class ThreatsState(AgentState):
|
|
205
|
+
gap_analysis_uses: NotRequired[int]
|
|
206
|
+
|
|
207
|
+
|
|
208
|
+
class ThreatsMiddleware(TaskMiddleware):
|
|
209
|
+
state_schema = ThreatsState
|
|
210
|
+
# ...
|
|
211
|
+
```
|
|
212
|
+
|
|
213
|
+
`TaskSteeringMiddleware` automatically merges all task middleware schemas into its own `state_schema`, so the fields survive checkpointing and interrupts.
|
|
214
|
+
|
|
215
|
+
## Configuration
|
|
216
|
+
|
|
217
|
+
| Parameter | Default | Description |
|
|
218
|
+
|---|---|---|
|
|
219
|
+
| `tasks` | *(required)* | Ordered list of `Task` definitions. |
|
|
220
|
+
| `global_tools` | `[]` | Tools available in every task. |
|
|
221
|
+
| `enforce_order` | `True` | Require tasks to be completed in definition order. |
|
|
222
|
+
|
|
223
|
+
### Task fields
|
|
224
|
+
|
|
225
|
+
| Field | Required | Description |
|
|
226
|
+
|---|---|---|
|
|
227
|
+
| `name` | yes | Unique identifier (used in prompts and state). |
|
|
228
|
+
| `instruction` | yes | Injected into system prompt when this task is active. |
|
|
229
|
+
| `tools` | yes | Tools visible when this task is `IN_PROGRESS`. |
|
|
230
|
+
| `middleware` | no | Scoped `TaskMiddleware` — only active during this task. |
|
|
231
|
+
|
|
232
|
+
## Composability
|
|
233
|
+
|
|
234
|
+
`TaskSteeringMiddleware` is a standard `AgentMiddleware`. It composes with other LangChain v1 middleware:
|
|
235
|
+
|
|
236
|
+
```python
|
|
237
|
+
from langchain.agents import create_agent
|
|
238
|
+
from langchain.agents.middleware import SummarizationMiddleware
|
|
239
|
+
|
|
240
|
+
agent = create_agent(
|
|
241
|
+
model="anthropic:claude-sonnet-4-6",
|
|
242
|
+
middleware=[
|
|
243
|
+
SummarizationMiddleware(
|
|
244
|
+
model="anthropic:claude-haiku-4-5-20251001",
|
|
245
|
+
trigger={"tokens": 8000},
|
|
246
|
+
),
|
|
247
|
+
pipeline,
|
|
248
|
+
],
|
|
249
|
+
)
|
|
250
|
+
```
|
|
251
|
+
|
|
252
|
+
## Development
|
|
253
|
+
|
|
254
|
+
```bash
|
|
255
|
+
# Install with dev dependencies
|
|
256
|
+
pip install -e ".[dev]"
|
|
257
|
+
|
|
258
|
+
# Run tests
|
|
259
|
+
pytest
|
|
260
|
+
|
|
261
|
+
# Run tests with coverage
|
|
262
|
+
pytest --cov=task_steering
|
|
263
|
+
```
|
|
264
|
+
|
|
265
|
+
## Project structure
|
|
266
|
+
|
|
267
|
+
```
|
|
268
|
+
task-steering/
|
|
269
|
+
src/task_steering/
|
|
270
|
+
__init__.py # Public exports
|
|
271
|
+
types.py # Task, TaskMiddleware, TaskStatus, TaskSteeringState
|
|
272
|
+
middleware.py # TaskSteeringMiddleware implementation
|
|
273
|
+
tests/
|
|
274
|
+
conftest.py # Fixtures and mock objects
|
|
275
|
+
test_middleware.py # Test suite
|
|
276
|
+
examples/
|
|
277
|
+
simple_agent.py # End-to-end example with Bedrock
|
|
278
|
+
pyproject.toml
|
|
279
|
+
```
|
|
280
|
+
|
|
281
|
+
## License
|
|
282
|
+
|
|
283
|
+
MIT — see [LICENSE](LICENSE).
|