doubleoh 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.
- doubleoh-0.1.0/.gitignore +28 -0
- doubleoh-0.1.0/PKG-INFO +105 -0
- doubleoh-0.1.0/README.md +97 -0
- doubleoh-0.1.0/doubleoh/__init__.py +224 -0
- doubleoh-0.1.0/doubleoh/crewai.py +29 -0
- doubleoh-0.1.0/doubleoh/langchain.py +34 -0
- doubleoh-0.1.0/doubleoh/llamaindex.py +25 -0
- doubleoh-0.1.0/doubleoh/tools.py +109 -0
- doubleoh-0.1.0/pyproject.toml +15 -0
- doubleoh-0.1.0/tests/test_client.py +132 -0
- doubleoh-0.1.0/tests/test_tools.py +120 -0
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
.worktrees/
|
|
2
|
+
.superpowers/
|
|
3
|
+
|
|
4
|
+
# Claude Code: .claude/skills/ is shared, the rest is local state
|
|
5
|
+
.claude/worktrees/
|
|
6
|
+
.claude/settings.local.json
|
|
7
|
+
docs/superpowers/
|
|
8
|
+
|
|
9
|
+
docs/specs/
|
|
10
|
+
docs/plans/
|
|
11
|
+
.env
|
|
12
|
+
.env.*
|
|
13
|
+
!.env.example
|
|
14
|
+
node_modules/
|
|
15
|
+
**/dist/
|
|
16
|
+
app/src/lib/generated/application-config.ts
|
|
17
|
+
.logs/
|
|
18
|
+
.demo-logs/
|
|
19
|
+
**/.impeccable
|
|
20
|
+
.wave-state.md
|
|
21
|
+
|
|
22
|
+
# TanStack Router scratch output
|
|
23
|
+
app/.tanstack/
|
|
24
|
+
openbot.apk
|
|
25
|
+
# Generated by `bunx cap add android`; rebuilt from capacitor.config.ts.
|
|
26
|
+
# Kept: `cap sync` regenerates the manifest, and the microphone permission lives here.
|
|
27
|
+
!app/android-overrides/
|
|
28
|
+
app/android/
|
doubleoh-0.1.0/PKG-INFO
ADDED
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
Metadata-Version: 2.5
|
|
2
|
+
Name: doubleoh
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Your agents learn from every fix. Stop failing the same way twice.
|
|
5
|
+
License: Proprietary
|
|
6
|
+
Requires-Python: >=3.9
|
|
7
|
+
Description-Content-Type: text/markdown
|
|
8
|
+
|
|
9
|
+
# doubleoh
|
|
10
|
+
|
|
11
|
+
**Your agents learn from every fix. Stop failing the same way twice.**
|
|
12
|
+
|
|
13
|
+
When your agent gets stuck on a real website, one of *your* people fixes it once in a live
|
|
14
|
+
browser. That fix compiles into a skill. The next time any agent in your fleet hits the same
|
|
15
|
+
wall, it follows the skill instead of asking a human.
|
|
16
|
+
|
|
17
|
+
```bash
|
|
18
|
+
pip install doubleoh
|
|
19
|
+
```
|
|
20
|
+
|
|
21
|
+
Zero dependencies — it is `urllib` and dataclasses.
|
|
22
|
+
|
|
23
|
+
## The loop, in code
|
|
24
|
+
|
|
25
|
+
```python
|
|
26
|
+
from doubleoh import DoubleOh
|
|
27
|
+
|
|
28
|
+
doubleoh = DoubleOh(api_key=os.environ["DOUBLEOH_API_KEY"])
|
|
29
|
+
|
|
30
|
+
# 1. Before your agent tries something it has failed at before, ask what has been learned.
|
|
31
|
+
skills = doubleoh.skills_for("check out on the supplier portal")
|
|
32
|
+
if skills and skills[0].times_worked > skills[0].times_failed:
|
|
33
|
+
prompt += f"\n\nA colleague has done this before:\n{skills[0].instructions}"
|
|
34
|
+
|
|
35
|
+
# 2. Your agent runs. When it gets stuck, ask for a human.
|
|
36
|
+
fix = doubleoh.request_fix(url=page.url, task="Check out on the supplier portal")
|
|
37
|
+
if fix.deflected:
|
|
38
|
+
# Nobody was paged: the fleet already knows this wall. Retry with the skill.
|
|
39
|
+
prompt += fix.skill.instructions
|
|
40
|
+
elif fix.fix_url:
|
|
41
|
+
slack.send(f"An agent needs a hand: {fix.fix_url}")
|
|
42
|
+
# fix.duplicate_of set? The wall is already waiting on a human — fix.blocked_runs says
|
|
43
|
+
# how many runs are stuck on it. Your retry loop needs no change; nobody gets re-paged.
|
|
44
|
+
|
|
45
|
+
# 3. Close the loop: say whether the skill worked. This is what makes the counts mean
|
|
46
|
+
# something, and a skill followed three times that never works retires itself.
|
|
47
|
+
doubleoh.report_skill(skills[0].name, worked=True)
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
You decide what "stuck" means — a timeout, a retry count, a model that says it cannot
|
|
51
|
+
proceed. Only you know your agent.
|
|
52
|
+
|
|
53
|
+
## API
|
|
54
|
+
|
|
55
|
+
- `skills_for(task) -> list[LearnedSkill]` — what has been learned; put `instructions` in
|
|
56
|
+
your agent's context. `times_worked` / `times_failed` are raw counts on purpose: 5 for 5
|
|
57
|
+
is worth following, 1 for 1 is a guess that happened to work once.
|
|
58
|
+
- `request_fix(url, task) -> Intervention` — ask for help. Three possible shapes: a
|
|
59
|
+
`fix_url` (a human was paged), `deflected=True` with `skill` (nobody was paged — you
|
|
60
|
+
already know the answer), or `duplicate_of` with `blocked_runs` (already waiting; triage
|
|
61
|
+
by the count). `url` must be a public page; private and loopback addresses are refused.
|
|
62
|
+
- `report_skill(name, worked)` — one boolean; wire it to whatever already tells you a run
|
|
63
|
+
succeeded.
|
|
64
|
+
- `intervention(id)` / `interventions()` — status; `skill_name` is the receipt.
|
|
65
|
+
- `retire_skill(name, reason)` — stop serving a skill immediately.
|
|
66
|
+
|
|
67
|
+
Errors raise `DoubleOhError` carrying the server's own message and the HTTP `status`.
|
|
68
|
+
|
|
69
|
+
## What is recorded
|
|
70
|
+
|
|
71
|
+
Fix sessions keep screenshots and the shape of each action. **Typed text is never
|
|
72
|
+
recorded** — a fix session is exactly where a password gets typed, so keystrokes stay off
|
|
73
|
+
the record entirely.
|
|
74
|
+
|
|
75
|
+
## Framework adapters
|
|
76
|
+
|
|
77
|
+
Every adapter serves the same three tools; pick your framework's line.
|
|
78
|
+
|
|
79
|
+
```python
|
|
80
|
+
from doubleoh import DoubleOh
|
|
81
|
+
client = DoubleOh(api_key=os.environ["DOUBLEOH_API_KEY"])
|
|
82
|
+
|
|
83
|
+
# LangChain / LangGraph
|
|
84
|
+
from doubleoh.langchain import doubleoh_tools
|
|
85
|
+
graph = create_react_agent(model, tools=doubleoh_tools(client))
|
|
86
|
+
|
|
87
|
+
# CrewAI
|
|
88
|
+
from doubleoh.crewai import doubleoh_tools
|
|
89
|
+
agent = Agent(role="operator", tools=doubleoh_tools(client))
|
|
90
|
+
|
|
91
|
+
# LlamaIndex
|
|
92
|
+
from doubleoh.llamaindex import doubleoh_tools
|
|
93
|
+
agent = ReActAgent.from_tools(doubleoh_tools(client), llm=llm)
|
|
94
|
+
|
|
95
|
+
# OpenAI Agents SDK, AutoGen, Pydantic AI — all accept plain functions:
|
|
96
|
+
from doubleoh.tools import build_tools
|
|
97
|
+
skills_for, request_fix, report_skill = build_tools(client)
|
|
98
|
+
agent = Agent(tools=[function_tool(skills_for), function_tool(request_fix), function_tool(report_skill)])
|
|
99
|
+
```
|
|
100
|
+
|
|
101
|
+
No framework is a dependency of this package: each adapter imports its framework lazily
|
|
102
|
+
and, if it is missing, raises one sentence naming what to `pip install`.
|
|
103
|
+
|
|
104
|
+
Using Claude or another MCP-capable agent? Skip the SDK entirely — `doubleoh-mcp` serves
|
|
105
|
+
the same three tools over MCP.
|
doubleoh-0.1.0/README.md
ADDED
|
@@ -0,0 +1,97 @@
|
|
|
1
|
+
# doubleoh
|
|
2
|
+
|
|
3
|
+
**Your agents learn from every fix. Stop failing the same way twice.**
|
|
4
|
+
|
|
5
|
+
When your agent gets stuck on a real website, one of *your* people fixes it once in a live
|
|
6
|
+
browser. That fix compiles into a skill. The next time any agent in your fleet hits the same
|
|
7
|
+
wall, it follows the skill instead of asking a human.
|
|
8
|
+
|
|
9
|
+
```bash
|
|
10
|
+
pip install doubleoh
|
|
11
|
+
```
|
|
12
|
+
|
|
13
|
+
Zero dependencies — it is `urllib` and dataclasses.
|
|
14
|
+
|
|
15
|
+
## The loop, in code
|
|
16
|
+
|
|
17
|
+
```python
|
|
18
|
+
from doubleoh import DoubleOh
|
|
19
|
+
|
|
20
|
+
doubleoh = DoubleOh(api_key=os.environ["DOUBLEOH_API_KEY"])
|
|
21
|
+
|
|
22
|
+
# 1. Before your agent tries something it has failed at before, ask what has been learned.
|
|
23
|
+
skills = doubleoh.skills_for("check out on the supplier portal")
|
|
24
|
+
if skills and skills[0].times_worked > skills[0].times_failed:
|
|
25
|
+
prompt += f"\n\nA colleague has done this before:\n{skills[0].instructions}"
|
|
26
|
+
|
|
27
|
+
# 2. Your agent runs. When it gets stuck, ask for a human.
|
|
28
|
+
fix = doubleoh.request_fix(url=page.url, task="Check out on the supplier portal")
|
|
29
|
+
if fix.deflected:
|
|
30
|
+
# Nobody was paged: the fleet already knows this wall. Retry with the skill.
|
|
31
|
+
prompt += fix.skill.instructions
|
|
32
|
+
elif fix.fix_url:
|
|
33
|
+
slack.send(f"An agent needs a hand: {fix.fix_url}")
|
|
34
|
+
# fix.duplicate_of set? The wall is already waiting on a human — fix.blocked_runs says
|
|
35
|
+
# how many runs are stuck on it. Your retry loop needs no change; nobody gets re-paged.
|
|
36
|
+
|
|
37
|
+
# 3. Close the loop: say whether the skill worked. This is what makes the counts mean
|
|
38
|
+
# something, and a skill followed three times that never works retires itself.
|
|
39
|
+
doubleoh.report_skill(skills[0].name, worked=True)
|
|
40
|
+
```
|
|
41
|
+
|
|
42
|
+
You decide what "stuck" means — a timeout, a retry count, a model that says it cannot
|
|
43
|
+
proceed. Only you know your agent.
|
|
44
|
+
|
|
45
|
+
## API
|
|
46
|
+
|
|
47
|
+
- `skills_for(task) -> list[LearnedSkill]` — what has been learned; put `instructions` in
|
|
48
|
+
your agent's context. `times_worked` / `times_failed` are raw counts on purpose: 5 for 5
|
|
49
|
+
is worth following, 1 for 1 is a guess that happened to work once.
|
|
50
|
+
- `request_fix(url, task) -> Intervention` — ask for help. Three possible shapes: a
|
|
51
|
+
`fix_url` (a human was paged), `deflected=True` with `skill` (nobody was paged — you
|
|
52
|
+
already know the answer), or `duplicate_of` with `blocked_runs` (already waiting; triage
|
|
53
|
+
by the count). `url` must be a public page; private and loopback addresses are refused.
|
|
54
|
+
- `report_skill(name, worked)` — one boolean; wire it to whatever already tells you a run
|
|
55
|
+
succeeded.
|
|
56
|
+
- `intervention(id)` / `interventions()` — status; `skill_name` is the receipt.
|
|
57
|
+
- `retire_skill(name, reason)` — stop serving a skill immediately.
|
|
58
|
+
|
|
59
|
+
Errors raise `DoubleOhError` carrying the server's own message and the HTTP `status`.
|
|
60
|
+
|
|
61
|
+
## What is recorded
|
|
62
|
+
|
|
63
|
+
Fix sessions keep screenshots and the shape of each action. **Typed text is never
|
|
64
|
+
recorded** — a fix session is exactly where a password gets typed, so keystrokes stay off
|
|
65
|
+
the record entirely.
|
|
66
|
+
|
|
67
|
+
## Framework adapters
|
|
68
|
+
|
|
69
|
+
Every adapter serves the same three tools; pick your framework's line.
|
|
70
|
+
|
|
71
|
+
```python
|
|
72
|
+
from doubleoh import DoubleOh
|
|
73
|
+
client = DoubleOh(api_key=os.environ["DOUBLEOH_API_KEY"])
|
|
74
|
+
|
|
75
|
+
# LangChain / LangGraph
|
|
76
|
+
from doubleoh.langchain import doubleoh_tools
|
|
77
|
+
graph = create_react_agent(model, tools=doubleoh_tools(client))
|
|
78
|
+
|
|
79
|
+
# CrewAI
|
|
80
|
+
from doubleoh.crewai import doubleoh_tools
|
|
81
|
+
agent = Agent(role="operator", tools=doubleoh_tools(client))
|
|
82
|
+
|
|
83
|
+
# LlamaIndex
|
|
84
|
+
from doubleoh.llamaindex import doubleoh_tools
|
|
85
|
+
agent = ReActAgent.from_tools(doubleoh_tools(client), llm=llm)
|
|
86
|
+
|
|
87
|
+
# OpenAI Agents SDK, AutoGen, Pydantic AI — all accept plain functions:
|
|
88
|
+
from doubleoh.tools import build_tools
|
|
89
|
+
skills_for, request_fix, report_skill = build_tools(client)
|
|
90
|
+
agent = Agent(tools=[function_tool(skills_for), function_tool(request_fix), function_tool(report_skill)])
|
|
91
|
+
```
|
|
92
|
+
|
|
93
|
+
No framework is a dependency of this package: each adapter imports its framework lazily
|
|
94
|
+
and, if it is missing, raises one sentence naming what to `pip install`.
|
|
95
|
+
|
|
96
|
+
Using Claude or another MCP-capable agent? Skip the SDK entirely — `doubleoh-mcp` serves
|
|
97
|
+
the same three tools over MCP.
|
|
@@ -0,0 +1,224 @@
|
|
|
1
|
+
"""The DoubleOh client, for an agent that would rather learn than fail twice.
|
|
2
|
+
|
|
3
|
+
The whole SDK is three moments: before you try something, ask what has been learned
|
|
4
|
+
(``skills_for``); when you get stuck, ask a human (``request_fix``); when you know how a
|
|
5
|
+
skill went, say so (``report_skill``). Everything else an integrator writes — the retry
|
|
6
|
+
loop, the "am I stuck" heuristic — is theirs, because only they know their agent.
|
|
7
|
+
|
|
8
|
+
No dependencies. It is ``urllib`` and dataclasses, readable in one sitting: a reliability
|
|
9
|
+
tool that drags a dependency tree into your build is a reliability problem.
|
|
10
|
+
"""
|
|
11
|
+
|
|
12
|
+
from __future__ import annotations
|
|
13
|
+
|
|
14
|
+
import json
|
|
15
|
+
import urllib.error
|
|
16
|
+
import urllib.request
|
|
17
|
+
from dataclasses import dataclass
|
|
18
|
+
from typing import Any, Optional
|
|
19
|
+
from urllib.parse import quote
|
|
20
|
+
|
|
21
|
+
__all__ = [
|
|
22
|
+
"DoubleOh",
|
|
23
|
+
"DoubleOhError",
|
|
24
|
+
"LearnedSkill",
|
|
25
|
+
"Intervention",
|
|
26
|
+
"InterventionStatus",
|
|
27
|
+
]
|
|
28
|
+
|
|
29
|
+
_DEFAULT_BASE_URL = "https://api.doubleoh.ai"
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
class DoubleOhError(Exception):
|
|
33
|
+
"""The server refused, and this is its own sentence about why."""
|
|
34
|
+
|
|
35
|
+
def __init__(self, message: str, status: int):
|
|
36
|
+
super().__init__(message)
|
|
37
|
+
self.status = status
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass(frozen=True)
|
|
41
|
+
class LearnedSkill:
|
|
42
|
+
"""A procedure learned from a past human fix, ready to hand to your agent."""
|
|
43
|
+
|
|
44
|
+
name: str
|
|
45
|
+
description: str
|
|
46
|
+
#: The steps, as prose. Put this in your agent's context before it tries the task.
|
|
47
|
+
instructions: str
|
|
48
|
+
#: How many agents followed this and reported it worked / did not. Raw counts on
|
|
49
|
+
#: purpose: 5 for 5 is worth following, 1 for 1 is a guess that happened to work
|
|
50
|
+
#: once, and a rate cannot tell those two apart.
|
|
51
|
+
times_worked: int = 0
|
|
52
|
+
times_failed: int = 0
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
@dataclass(frozen=True)
|
|
56
|
+
class Intervention:
|
|
57
|
+
"""What came back from asking for help.
|
|
58
|
+
|
|
59
|
+
Exactly one of three shapes:
|
|
60
|
+
- ``fix_url`` is set: a human has been asked; send the link where your team lives.
|
|
61
|
+
- ``deflected`` is True: nobody was paged — ``skill`` already answers this wall.
|
|
62
|
+
Retry with its instructions in context; if that still fails, ask again (the same
|
|
63
|
+
wall deflects only once, so the second ask reaches a human).
|
|
64
|
+
- ``duplicate_of`` is set: this wall is already waiting on a human. ``blocked_runs``
|
|
65
|
+
says how many runs are stuck on it, yours included — triage by it.
|
|
66
|
+
"""
|
|
67
|
+
|
|
68
|
+
id: Optional[str]
|
|
69
|
+
fix_url: Optional[str]
|
|
70
|
+
prepared: bool
|
|
71
|
+
duplicate_of: Optional[str] = None
|
|
72
|
+
waiting_since: Optional[str] = None
|
|
73
|
+
blocked_runs: Optional[int] = None
|
|
74
|
+
deflected: bool = False
|
|
75
|
+
skill: Optional[LearnedSkill] = None
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
@dataclass(frozen=True)
|
|
79
|
+
class InterventionStatus:
|
|
80
|
+
id: str
|
|
81
|
+
url: str
|
|
82
|
+
task: str
|
|
83
|
+
#: ``open``, ``resolved``, ``abandoned``, or ``expired``.
|
|
84
|
+
status: str
|
|
85
|
+
#: The skill this fix produced, once it has. None until then, or if nothing was learned.
|
|
86
|
+
skill_name: Optional[str]
|
|
87
|
+
#: How many runs hit this wall while it was open. The triage number.
|
|
88
|
+
blocked_runs: int
|
|
89
|
+
created_at: str
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
def _skill_from(payload: dict[str, Any]) -> LearnedSkill:
|
|
93
|
+
return LearnedSkill(
|
|
94
|
+
name=payload["name"],
|
|
95
|
+
description=payload.get("description", ""),
|
|
96
|
+
instructions=payload.get("instructions", ""),
|
|
97
|
+
times_worked=payload.get("timesWorked", 0),
|
|
98
|
+
times_failed=payload.get("timesFailed", 0),
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
|
|
102
|
+
class DoubleOh:
|
|
103
|
+
"""One customer's handle on the loop.
|
|
104
|
+
|
|
105
|
+
>>> doubleoh = DoubleOh(api_key="oo_live_...")
|
|
106
|
+
>>> skills = doubleoh.skills_for("check out on the supplier portal")
|
|
107
|
+
>>> if skills:
|
|
108
|
+
... prompt += skills[0].instructions
|
|
109
|
+
"""
|
|
110
|
+
|
|
111
|
+
def __init__(
|
|
112
|
+
self,
|
|
113
|
+
api_key: str,
|
|
114
|
+
base_url: str = _DEFAULT_BASE_URL,
|
|
115
|
+
timeout: float = 30.0,
|
|
116
|
+
):
|
|
117
|
+
if not api_key or not api_key.startswith("oo_"):
|
|
118
|
+
raise ValueError("A DoubleOh API key is required (it starts with oo_).")
|
|
119
|
+
self._api_key = api_key
|
|
120
|
+
self._base_url = base_url.rstrip("/")
|
|
121
|
+
self._timeout = timeout
|
|
122
|
+
|
|
123
|
+
# ---- the three moments -------------------------------------------------
|
|
124
|
+
|
|
125
|
+
def skills_for(self, task: str) -> list[LearnedSkill]:
|
|
126
|
+
"""What has been learned that helps with this task.
|
|
127
|
+
|
|
128
|
+
Call this BEFORE your agent attempts something it has failed at before. If a
|
|
129
|
+
skill comes back, put its ``instructions`` in the agent's context — that is the
|
|
130
|
+
whole point of the loop, the moment a past human's fix saves this run.
|
|
131
|
+
"""
|
|
132
|
+
body = self._get(f"/v1/skills?task={quote(task)}")
|
|
133
|
+
return [_skill_from(skill) for skill in body["skills"]]
|
|
134
|
+
|
|
135
|
+
def request_fix(self, url: str, task: str) -> Intervention:
|
|
136
|
+
"""Ask a human to finish what your agent could not.
|
|
137
|
+
|
|
138
|
+
``url`` must be a public http(s) page — the page your agent is stuck on. ``task``
|
|
139
|
+
is one sentence (max 500 chars); phrase it the same way each time so repeated
|
|
140
|
+
fixes of one wall stay one skill.
|
|
141
|
+
"""
|
|
142
|
+
body = self._post("/v1/interventions", {"url": url, "task": task})
|
|
143
|
+
raw = body["intervention"]
|
|
144
|
+
skill = raw.get("skill")
|
|
145
|
+
return Intervention(
|
|
146
|
+
id=raw.get("id"),
|
|
147
|
+
fix_url=raw.get("fixUrl"),
|
|
148
|
+
prepared=bool(raw.get("prepared", False)),
|
|
149
|
+
duplicate_of=raw.get("duplicateOf"),
|
|
150
|
+
waiting_since=raw.get("waitingSince"),
|
|
151
|
+
blocked_runs=raw.get("blockedRuns"),
|
|
152
|
+
deflected=bool(raw.get("deflected", False)),
|
|
153
|
+
skill=_skill_from(skill) if skill else None,
|
|
154
|
+
)
|
|
155
|
+
|
|
156
|
+
def report_skill(self, name: str, worked: bool) -> None:
|
|
157
|
+
"""Say whether a skill worked.
|
|
158
|
+
|
|
159
|
+
This closes the loop, and it is the one call people are tempted to skip. Without
|
|
160
|
+
it a skill compiled from a single recording is served forever with nothing
|
|
161
|
+
checking it. One boolean, so it costs nothing to wire into whatever already tells
|
|
162
|
+
you a run succeeded. A skill followed three times that never works retires itself.
|
|
163
|
+
"""
|
|
164
|
+
self._post(f"/v1/skills/{quote(name, safe='')}/outcome", {"worked": worked})
|
|
165
|
+
|
|
166
|
+
# ---- the rest ------------------------------------------------------------
|
|
167
|
+
|
|
168
|
+
def intervention(self, intervention_id: str) -> InterventionStatus:
|
|
169
|
+
"""How one fix is going, and — once resolved — the skill it produced."""
|
|
170
|
+
body = self._get(f"/v1/interventions/{quote(intervention_id, safe='')}")
|
|
171
|
+
return self._status_from(body["intervention"])
|
|
172
|
+
|
|
173
|
+
def interventions(self) -> list[InterventionStatus]:
|
|
174
|
+
"""Recent fixes across your fleet, newest first."""
|
|
175
|
+
body = self._get("/v1/interventions")
|
|
176
|
+
return [self._status_from(row) for row in body["interventions"]]
|
|
177
|
+
|
|
178
|
+
def retire_skill(self, name: str, reason: str) -> None:
|
|
179
|
+
"""Stop serving a learned skill that turned out to be wrong, immediately."""
|
|
180
|
+
self._post(f"/v1/skills/{quote(name, safe='')}/retire", {"reason": reason})
|
|
181
|
+
|
|
182
|
+
# ---- wire ------------------------------------------------------------------
|
|
183
|
+
|
|
184
|
+
@staticmethod
|
|
185
|
+
def _status_from(raw: dict[str, Any]) -> InterventionStatus:
|
|
186
|
+
return InterventionStatus(
|
|
187
|
+
id=raw["id"],
|
|
188
|
+
url=raw["url"],
|
|
189
|
+
task=raw["task"],
|
|
190
|
+
status=raw["status"],
|
|
191
|
+
skill_name=raw.get("skillName"),
|
|
192
|
+
blocked_runs=raw.get("blockedRuns", 1),
|
|
193
|
+
created_at=raw.get("createdAt", ""),
|
|
194
|
+
)
|
|
195
|
+
|
|
196
|
+
def _get(self, path: str) -> dict[str, Any]:
|
|
197
|
+
return self._request("GET", path, None)
|
|
198
|
+
|
|
199
|
+
def _post(self, path: str, payload: dict[str, Any]) -> dict[str, Any]:
|
|
200
|
+
return self._request("POST", path, payload)
|
|
201
|
+
|
|
202
|
+
def _request(self, method: str, path: str, payload: Optional[dict[str, Any]]) -> dict[str, Any]:
|
|
203
|
+
request = urllib.request.Request(
|
|
204
|
+
f"{self._base_url}{path}",
|
|
205
|
+
method=method,
|
|
206
|
+
headers={
|
|
207
|
+
"x-api-key": self._api_key,
|
|
208
|
+
"content-type": "application/json",
|
|
209
|
+
},
|
|
210
|
+
data=json.dumps(payload).encode() if payload is not None else None,
|
|
211
|
+
)
|
|
212
|
+
try:
|
|
213
|
+
with urllib.request.urlopen(request, timeout=self._timeout) as response:
|
|
214
|
+
return json.loads(response.read().decode() or "{}")
|
|
215
|
+
except urllib.error.HTTPError as error:
|
|
216
|
+
# The server's own message names what went wrong; the status is the fallback.
|
|
217
|
+
try:
|
|
218
|
+
detail = json.loads(error.read().decode())
|
|
219
|
+
message = detail.get("error") or f"DoubleOh answered {error.code}"
|
|
220
|
+
except Exception:
|
|
221
|
+
message = f"DoubleOh answered {error.code}"
|
|
222
|
+
raise DoubleOhError(message, error.code) from None
|
|
223
|
+
except urllib.error.URLError as error:
|
|
224
|
+
raise DoubleOhError(f"Could not reach DoubleOh: {error.reason}", 0) from None
|
|
@@ -0,0 +1,29 @@
|
|
|
1
|
+
"""CrewAI adapter: the three tools for a crew's agents.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
from doubleoh import DoubleOh
|
|
6
|
+
from doubleoh.crewai import doubleoh_tools
|
|
7
|
+
|
|
8
|
+
agent = Agent(role=..., tools=doubleoh_tools(DoubleOh(api_key=...)))
|
|
9
|
+
|
|
10
|
+
CrewAI's ``tool`` decorator builds a tool from a plain function's name, signature, and
|
|
11
|
+
docstring — which is exactly what ``doubleoh.tools`` provides, so this module is only the
|
|
12
|
+
wrapping.
|
|
13
|
+
"""
|
|
14
|
+
|
|
15
|
+
from __future__ import annotations
|
|
16
|
+
|
|
17
|
+
from . import DoubleOh
|
|
18
|
+
from .tools import build_tools
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def doubleoh_tools(client: DoubleOh) -> list:
|
|
22
|
+
try:
|
|
23
|
+
from crewai.tools import tool
|
|
24
|
+
except ImportError as error: # pragma: no cover - exercised only without crewai
|
|
25
|
+
raise ImportError(
|
|
26
|
+
"The CrewAI adapter needs crewai: pip install crewai"
|
|
27
|
+
) from error
|
|
28
|
+
|
|
29
|
+
return [tool(func) for func in build_tools(client)]
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
"""LangChain / LangGraph adapter: the three tools as LangChain ``Tool`` objects.
|
|
2
|
+
|
|
3
|
+
LangGraph agents consume LangChain tools, so this one module covers both::
|
|
4
|
+
|
|
5
|
+
from doubleoh import DoubleOh
|
|
6
|
+
from doubleoh.langchain import doubleoh_tools
|
|
7
|
+
|
|
8
|
+
tools = doubleoh_tools(DoubleOh(api_key=...))
|
|
9
|
+
graph = create_react_agent(model, tools=tools) # LangGraph
|
|
10
|
+
agent = initialize_agent(tools, llm, ...) # classic LangChain
|
|
11
|
+
|
|
12
|
+
The framework import happens inside the call, so importing this module costs nothing and
|
|
13
|
+
failing is a sentence about what to install rather than an ImportError from the middle of
|
|
14
|
+
somebody's dependency tree.
|
|
15
|
+
"""
|
|
16
|
+
|
|
17
|
+
from __future__ import annotations
|
|
18
|
+
|
|
19
|
+
from . import DoubleOh
|
|
20
|
+
from .tools import build_tools
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def doubleoh_tools(client: DoubleOh) -> list:
|
|
24
|
+
try:
|
|
25
|
+
from langchain_core.tools import StructuredTool
|
|
26
|
+
except ImportError as error: # pragma: no cover - exercised only without langchain
|
|
27
|
+
raise ImportError(
|
|
28
|
+
"The LangChain adapter needs langchain-core: pip install langchain-core"
|
|
29
|
+
) from error
|
|
30
|
+
|
|
31
|
+
return [
|
|
32
|
+
StructuredTool.from_function(func)
|
|
33
|
+
for func in build_tools(client)
|
|
34
|
+
]
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
"""LlamaIndex adapter: the three tools as ``FunctionTool`` objects.
|
|
2
|
+
|
|
3
|
+
::
|
|
4
|
+
|
|
5
|
+
from doubleoh import DoubleOh
|
|
6
|
+
from doubleoh.llamaindex import doubleoh_tools
|
|
7
|
+
|
|
8
|
+
agent = ReActAgent.from_tools(doubleoh_tools(DoubleOh(api_key=...)), llm=llm)
|
|
9
|
+
"""
|
|
10
|
+
|
|
11
|
+
from __future__ import annotations
|
|
12
|
+
|
|
13
|
+
from . import DoubleOh
|
|
14
|
+
from .tools import build_tools
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def doubleoh_tools(client: DoubleOh) -> list:
|
|
18
|
+
try:
|
|
19
|
+
from llama_index.core.tools import FunctionTool
|
|
20
|
+
except ImportError as error: # pragma: no cover - exercised only without llama-index
|
|
21
|
+
raise ImportError(
|
|
22
|
+
"The LlamaIndex adapter needs llama-index-core: pip install llama-index-core"
|
|
23
|
+
) from error
|
|
24
|
+
|
|
25
|
+
return [FunctionTool.from_defaults(fn=func) for func in build_tools(client)]
|
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
"""The loop as agent tools — plain functions, which is what most frameworks now want.
|
|
2
|
+
|
|
3
|
+
The OpenAI Agents SDK, AutoGen, and Pydantic AI all accept ordinary Python callables as
|
|
4
|
+
tools, reading the name, signature, and docstring. So the base adapter is exactly that:
|
|
5
|
+
``build_tools(client)`` returns three well-documented functions, and those three
|
|
6
|
+
frameworks need nothing else::
|
|
7
|
+
|
|
8
|
+
from doubleoh import DoubleOh
|
|
9
|
+
from doubleoh.tools import build_tools
|
|
10
|
+
|
|
11
|
+
tools = build_tools(DoubleOh(api_key=...))
|
|
12
|
+
|
|
13
|
+
# OpenAI Agents SDK: Agent(tools=[function_tool(f) for f in tools])
|
|
14
|
+
# AutoGen: AssistantAgent(tools=list(tools))
|
|
15
|
+
# Pydantic AI: Agent(tools=list(tools))
|
|
16
|
+
|
|
17
|
+
The functions return STRINGS written for a model to read: a tool result is context, not
|
|
18
|
+
an API payload, and "A colleague has done this before: …" steers a model where a JSON
|
|
19
|
+
blob does not. LangChain/LangGraph, CrewAI, and LlamaIndex have their own modules that
|
|
20
|
+
wrap these same functions in each framework's tool class.
|
|
21
|
+
"""
|
|
22
|
+
|
|
23
|
+
from __future__ import annotations
|
|
24
|
+
|
|
25
|
+
from typing import Callable
|
|
26
|
+
|
|
27
|
+
from . import DoubleOh, DoubleOhError, LearnedSkill
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def _skill_lines(skill: LearnedSkill) -> str:
|
|
31
|
+
return (
|
|
32
|
+
f'Skill "{skill.name}" (worked {skill.times_worked}×, failed {skill.times_failed}×):\n'
|
|
33
|
+
f"{skill.description}\n{skill.instructions}"
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def build_tools(client: DoubleOh) -> tuple[Callable, Callable, Callable]:
|
|
38
|
+
"""The three tools, bound to one client, in the order an agent uses them."""
|
|
39
|
+
|
|
40
|
+
def doubleoh_skills_for(task: str) -> str:
|
|
41
|
+
"""BEFORE attempting a task you have failed at before, ask what has been learned.
|
|
42
|
+
|
|
43
|
+
Returns step-by-step procedures compiled from past human fixes, with how often
|
|
44
|
+
each has worked. Follow a returned procedure instead of improvising.
|
|
45
|
+
|
|
46
|
+
Args:
|
|
47
|
+
task: The task, in one sentence. Phrase it the same way each time — the
|
|
48
|
+
phrasing is part of the skill's identity.
|
|
49
|
+
"""
|
|
50
|
+
skills = client.skills_for(task)
|
|
51
|
+
if not skills:
|
|
52
|
+
return (
|
|
53
|
+
"Nothing has been learned about this task yet. Proceed, and if you get "
|
|
54
|
+
"stuck, use doubleoh_request_fix."
|
|
55
|
+
)
|
|
56
|
+
rendered = "\n\n".join(_skill_lines(skill) for skill in skills)
|
|
57
|
+
return f"A colleague has done this before. Follow these steps:\n\n{rendered}"
|
|
58
|
+
|
|
59
|
+
def doubleoh_request_fix(url: str, task: str) -> str:
|
|
60
|
+
"""When you are STUCK on a web page, ask a human to fix it.
|
|
61
|
+
|
|
62
|
+
A login wall, a changed layout, a step you cannot complete — a person fixes it
|
|
63
|
+
once in a live browser and the fix becomes a skill the whole fleet reuses. If the
|
|
64
|
+
fleet already knows this wall, the learned procedure is returned instantly
|
|
65
|
+
instead and nobody is paged: follow it and retry. Do not call this for tasks you
|
|
66
|
+
have not attempted.
|
|
67
|
+
|
|
68
|
+
Args:
|
|
69
|
+
url: The public http(s) page you are stuck on.
|
|
70
|
+
task: What you were trying to do, in one sentence (max 500 characters).
|
|
71
|
+
"""
|
|
72
|
+
try:
|
|
73
|
+
fix = client.request_fix(url=url, task=task)
|
|
74
|
+
except DoubleOhError as error:
|
|
75
|
+
# A refusal is an answer the model should read, not an exception to crash on.
|
|
76
|
+
return f"DoubleOh refused ({error.status}): {error}"
|
|
77
|
+
if fix.deflected and fix.skill:
|
|
78
|
+
return (
|
|
79
|
+
"No human was needed — this wall is already known. Follow this procedure "
|
|
80
|
+
f"and retry:\n\n{_skill_lines(fix.skill)}\n\n"
|
|
81
|
+
"If it still fails, call doubleoh_request_fix again: the second ask "
|
|
82
|
+
"reaches a human."
|
|
83
|
+
)
|
|
84
|
+
if fix.duplicate_of:
|
|
85
|
+
blocked = fix.blocked_runs or "several"
|
|
86
|
+
return (
|
|
87
|
+
f"A human has already been asked about this exact wall ({blocked} runs "
|
|
88
|
+
"are blocked on it). Do not ask again; move to other work or wait."
|
|
89
|
+
)
|
|
90
|
+
return (
|
|
91
|
+
f"A human has been asked to fix this. Fix link (already sent to the team if "
|
|
92
|
+
f"a webhook is configured): {fix.fix_url}. Move on to other work; the fix "
|
|
93
|
+
"becomes a reusable skill once a person completes it."
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
def doubleoh_report_skill(name: str, worked: bool) -> str:
|
|
97
|
+
"""AFTER following a skill, report whether it worked.
|
|
98
|
+
|
|
99
|
+
This keeps the skill's track record honest; a skill that keeps failing stops
|
|
100
|
+
being served. Always call this when you used a skill.
|
|
101
|
+
|
|
102
|
+
Args:
|
|
103
|
+
name: The skill's name, exactly as returned.
|
|
104
|
+
worked: True if following the skill completed the task.
|
|
105
|
+
"""
|
|
106
|
+
client.report_skill(name, worked)
|
|
107
|
+
return "Recorded. Thank you — the skill's track record is what the next agent trusts."
|
|
108
|
+
|
|
109
|
+
return doubleoh_skills_for, doubleoh_request_fix, doubleoh_report_skill
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
[build-system]
|
|
2
|
+
requires = ["hatchling"]
|
|
3
|
+
build-backend = "hatchling.build"
|
|
4
|
+
|
|
5
|
+
[project]
|
|
6
|
+
name = "doubleoh"
|
|
7
|
+
version = "0.1.0"
|
|
8
|
+
description = "Your agents learn from every fix. Stop failing the same way twice."
|
|
9
|
+
readme = "README.md"
|
|
10
|
+
requires-python = ">=3.9"
|
|
11
|
+
license = { text = "Proprietary" }
|
|
12
|
+
dependencies = []
|
|
13
|
+
|
|
14
|
+
[tool.hatch.build.targets.wheel]
|
|
15
|
+
packages = ["doubleoh"]
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
"""The Python SDK against a real HTTP server — stdlib only, run with `python3 -m unittest`.
|
|
2
|
+
|
|
3
|
+
A stub server rather than mocked urllib, because what the SDK owns is the wire: paths,
|
|
4
|
+
headers, encoding, and how a refusal becomes an exception. A mock of urllib would pass
|
|
5
|
+
while the wire was wrong.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import threading
|
|
10
|
+
import unittest
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
import pathlib
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
|
17
|
+
from doubleoh import Intervention, DoubleOh, DoubleOhError # noqa: E402
|
|
18
|
+
|
|
19
|
+
RECORDED: list[dict] = []
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class Stub(BaseHTTPRequestHandler):
|
|
23
|
+
def log_message(self, *args): # quiet
|
|
24
|
+
pass
|
|
25
|
+
|
|
26
|
+
def _reply(self, status, payload):
|
|
27
|
+
body = json.dumps(payload).encode()
|
|
28
|
+
self.send_response(status)
|
|
29
|
+
self.send_header("content-type", "application/json")
|
|
30
|
+
self.send_header("content-length", str(len(body)))
|
|
31
|
+
self.end_headers()
|
|
32
|
+
self.wfile.write(body)
|
|
33
|
+
|
|
34
|
+
def do_GET(self):
|
|
35
|
+
RECORDED.append({"method": "GET", "path": self.path, "key": self.headers.get("x-api-key")})
|
|
36
|
+
if self.path.startswith("/v1/skills"):
|
|
37
|
+
self._reply(200, {"skills": [{
|
|
38
|
+
"name": "checkout-finish-2afd60",
|
|
39
|
+
"description": "Use when finishing checkout.",
|
|
40
|
+
"instructions": "1. Click checkout.",
|
|
41
|
+
"timesWorked": 5,
|
|
42
|
+
"timesFailed": 0,
|
|
43
|
+
}]})
|
|
44
|
+
elif self.path == "/v1/interventions":
|
|
45
|
+
self._reply(200, {"interventions": [{
|
|
46
|
+
"id": "i-1", "url": "https://shop.example", "task": "t",
|
|
47
|
+
"status": "resolved", "skillName": "checkout-finish-2afd60",
|
|
48
|
+
"blockedRuns": 3, "createdAt": "2026-08-28T00:00:00Z",
|
|
49
|
+
}]})
|
|
50
|
+
else:
|
|
51
|
+
self._reply(404, {"error": "Not found."})
|
|
52
|
+
|
|
53
|
+
def do_POST(self):
|
|
54
|
+
length = int(self.headers.get("content-length", 0))
|
|
55
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
56
|
+
RECORDED.append({"method": "POST", "path": self.path, "body": body,
|
|
57
|
+
"key": self.headers.get("x-api-key")})
|
|
58
|
+
if self.path == "/v1/interventions":
|
|
59
|
+
if body.get("task") == "a known wall":
|
|
60
|
+
self._reply(200, {"intervention": {
|
|
61
|
+
"id": None, "fixUrl": None, "prepared": False, "deflected": True,
|
|
62
|
+
"skill": {"name": "s", "description": "d", "instructions": "1. x",
|
|
63
|
+
"timesWorked": 2, "timesFailed": 0},
|
|
64
|
+
}})
|
|
65
|
+
elif body.get("url", "").startswith("http://localhost"):
|
|
66
|
+
self._reply(400, {"error": "That address is not reachable for a fix."})
|
|
67
|
+
else:
|
|
68
|
+
self._reply(200, {"intervention": {
|
|
69
|
+
"id": "i-9", "fixUrl": "https://fix.example/fix/tok",
|
|
70
|
+
"prepared": True,
|
|
71
|
+
}})
|
|
72
|
+
elif self.path.endswith("/outcome"):
|
|
73
|
+
self._reply(200, {"recorded": True, "retired": False})
|
|
74
|
+
else:
|
|
75
|
+
self._reply(404, {"error": "No skill of yours by that name."})
|
|
76
|
+
|
|
77
|
+
|
|
78
|
+
class SdkTest(unittest.TestCase):
|
|
79
|
+
@classmethod
|
|
80
|
+
def setUpClass(cls):
|
|
81
|
+
cls.server = HTTPServer(("127.0.0.1", 0), Stub)
|
|
82
|
+
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
|
|
83
|
+
cls.client = DoubleOh(api_key="oo_test_1", base_url=f"http://127.0.0.1:{cls.server.server_port}")
|
|
84
|
+
|
|
85
|
+
@classmethod
|
|
86
|
+
def tearDownClass(cls):
|
|
87
|
+
cls.server.shutdown()
|
|
88
|
+
|
|
89
|
+
def test_rejects_a_key_that_is_not_a_doubleoh_key(self):
|
|
90
|
+
with self.assertRaises(ValueError):
|
|
91
|
+
DoubleOh(api_key="sk-something-else")
|
|
92
|
+
|
|
93
|
+
def test_skills_for_returns_typed_skills_with_confidence(self):
|
|
94
|
+
skills = self.client.skills_for("finish checkout")
|
|
95
|
+
self.assertEqual(skills[0].name, "checkout-finish-2afd60")
|
|
96
|
+
self.assertEqual(skills[0].times_worked, 5)
|
|
97
|
+
# The key travels as a header, and the task travels URL-encoded.
|
|
98
|
+
sent = RECORDED[-1]
|
|
99
|
+
self.assertEqual(sent["key"], "oo_test_1")
|
|
100
|
+
self.assertIn("finish%20checkout", sent["path"])
|
|
101
|
+
|
|
102
|
+
def test_request_fix_returns_the_link(self):
|
|
103
|
+
fix = self.client.request_fix("https://shop.example/checkout", "finish checkout")
|
|
104
|
+
self.assertIsInstance(fix, Intervention)
|
|
105
|
+
self.assertEqual(fix.fix_url, "https://fix.example/fix/tok")
|
|
106
|
+
self.assertFalse(fix.deflected)
|
|
107
|
+
|
|
108
|
+
def test_a_deflection_carries_the_skill_instead_of_a_link(self):
|
|
109
|
+
fix = self.client.request_fix("https://shop.example/checkout", "a known wall")
|
|
110
|
+
self.assertTrue(fix.deflected)
|
|
111
|
+
self.assertIsNone(fix.fix_url)
|
|
112
|
+
self.assertEqual(fix.skill.instructions, "1. x")
|
|
113
|
+
|
|
114
|
+
def test_a_refusal_raises_the_servers_own_sentence(self):
|
|
115
|
+
with self.assertRaises(DoubleOhError) as caught:
|
|
116
|
+
self.client.request_fix("http://localhost:3001/admin", "reach the inside")
|
|
117
|
+
self.assertEqual(caught.exception.status, 400)
|
|
118
|
+
self.assertIn("not reachable", str(caught.exception))
|
|
119
|
+
|
|
120
|
+
def test_report_skill_sends_one_boolean(self):
|
|
121
|
+
self.client.report_skill("checkout-finish-2afd60", True)
|
|
122
|
+
self.assertEqual(RECORDED[-1]["body"], {"worked": True})
|
|
123
|
+
self.assertIn("/v1/skills/checkout-finish-2afd60/outcome", RECORDED[-1]["path"])
|
|
124
|
+
|
|
125
|
+
def test_interventions_carry_the_triage_number(self):
|
|
126
|
+
rows = self.client.interventions()
|
|
127
|
+
self.assertEqual(rows[0].blocked_runs, 3)
|
|
128
|
+
self.assertEqual(rows[0].skill_name, "checkout-finish-2afd60")
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
if __name__ == "__main__":
|
|
132
|
+
unittest.main()
|
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
"""The plain-function tools against the same stub server as the client tests.
|
|
2
|
+
|
|
3
|
+
These functions are what every framework adapter serves, so what is asserted here is the
|
|
4
|
+
contract all of them inherit: model-readable strings, refusals as answers, and the
|
|
5
|
+
deflection path telling the agent to retry rather than wait.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
import json
|
|
9
|
+
import threading
|
|
10
|
+
import unittest
|
|
11
|
+
from http.server import BaseHTTPRequestHandler, HTTPServer
|
|
12
|
+
|
|
13
|
+
import sys
|
|
14
|
+
import pathlib
|
|
15
|
+
|
|
16
|
+
sys.path.insert(0, str(pathlib.Path(__file__).resolve().parent.parent))
|
|
17
|
+
from doubleoh import DoubleOh # noqa: E402
|
|
18
|
+
from doubleoh.tools import build_tools # noqa: E402
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Stub(BaseHTTPRequestHandler):
|
|
22
|
+
def log_message(self, *args):
|
|
23
|
+
pass
|
|
24
|
+
|
|
25
|
+
def _reply(self, status, payload):
|
|
26
|
+
body = json.dumps(payload).encode()
|
|
27
|
+
self.send_response(status)
|
|
28
|
+
self.send_header("content-type", "application/json")
|
|
29
|
+
self.send_header("content-length", str(len(body)))
|
|
30
|
+
self.end_headers()
|
|
31
|
+
self.wfile.write(body)
|
|
32
|
+
|
|
33
|
+
def do_GET(self):
|
|
34
|
+
if "empty" in self.path:
|
|
35
|
+
self._reply(200, {"skills": []})
|
|
36
|
+
else:
|
|
37
|
+
self._reply(200, {"skills": [{
|
|
38
|
+
"name": "checkout-finish-2afd60",
|
|
39
|
+
"description": "Use when finishing checkout.",
|
|
40
|
+
"instructions": "1. Click checkout.",
|
|
41
|
+
"timesWorked": 5, "timesFailed": 0,
|
|
42
|
+
}]})
|
|
43
|
+
|
|
44
|
+
def do_POST(self):
|
|
45
|
+
length = int(self.headers.get("content-length", 0))
|
|
46
|
+
body = json.loads(self.rfile.read(length) or b"{}")
|
|
47
|
+
if self.path.endswith("/outcome"):
|
|
48
|
+
self._reply(200, {"recorded": True, "retired": False})
|
|
49
|
+
elif body.get("task") == "a known wall":
|
|
50
|
+
self._reply(200, {"intervention": {
|
|
51
|
+
"id": None, "fixUrl": None, "prepared": False, "deflected": True,
|
|
52
|
+
"skill": {"name": "s", "description": "d",
|
|
53
|
+
"instructions": "1. Dismiss the wall.",
|
|
54
|
+
"timesWorked": 2, "timesFailed": 0}}})
|
|
55
|
+
elif body.get("url", "").startswith("http://localhost"):
|
|
56
|
+
self._reply(400, {"error": "That address is not reachable for a fix."})
|
|
57
|
+
else:
|
|
58
|
+
self._reply(201, {"intervention": {
|
|
59
|
+
"id": "i-9", "fixUrl": "https://fix.example/fix/tok", "prepared": True}})
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class ToolsTest(unittest.TestCase):
|
|
63
|
+
@classmethod
|
|
64
|
+
def setUpClass(cls):
|
|
65
|
+
cls.server = HTTPServer(("127.0.0.1", 0), Stub)
|
|
66
|
+
threading.Thread(target=cls.server.serve_forever, daemon=True).start()
|
|
67
|
+
client = DoubleOh(api_key="oo_test_1", base_url=f"http://127.0.0.1:{cls.server.server_port}")
|
|
68
|
+
cls.skills_for, cls.request_fix, cls.report_skill = build_tools(client)
|
|
69
|
+
|
|
70
|
+
@classmethod
|
|
71
|
+
def tearDownClass(cls):
|
|
72
|
+
cls.server.shutdown()
|
|
73
|
+
|
|
74
|
+
def test_functions_carry_the_names_and_docs_frameworks_read(self):
|
|
75
|
+
# Plain-function frameworks (OpenAI Agents, AutoGen, Pydantic AI) build the tool
|
|
76
|
+
# from exactly these attributes; if they drift, every adapter drifts.
|
|
77
|
+
self.assertEqual(type(self).skills_for.__name__, "doubleoh_skills_for")
|
|
78
|
+
self.assertIn("BEFORE attempting", type(self).skills_for.__doc__)
|
|
79
|
+
self.assertIn("STUCK", type(self).request_fix.__doc__)
|
|
80
|
+
self.assertIn("AFTER following a skill", type(self).report_skill.__doc__)
|
|
81
|
+
|
|
82
|
+
def test_skills_render_as_context_to_follow(self):
|
|
83
|
+
text = type(self).skills_for("finish checkout")
|
|
84
|
+
self.assertIn("A colleague has done this before", text)
|
|
85
|
+
self.assertIn("worked 5×", text)
|
|
86
|
+
|
|
87
|
+
def test_no_skills_points_at_request_fix(self):
|
|
88
|
+
text = type(self).skills_for("empty task nothing knows")
|
|
89
|
+
self.assertIn("Nothing has been learned", text)
|
|
90
|
+
self.assertIn("doubleoh_request_fix", text)
|
|
91
|
+
|
|
92
|
+
def test_deflection_says_retry_not_wait(self):
|
|
93
|
+
text = type(self).request_fix("https://shop.example", "a known wall")
|
|
94
|
+
self.assertIn("No human was needed", text)
|
|
95
|
+
self.assertIn("1. Dismiss the wall.", text)
|
|
96
|
+
|
|
97
|
+
def test_a_fresh_fix_returns_the_link_and_says_move_on(self):
|
|
98
|
+
text = type(self).request_fix("https://shop.example", "a brand new wall")
|
|
99
|
+
self.assertIn("https://fix.example/fix/tok", text)
|
|
100
|
+
self.assertIn("Move on", text)
|
|
101
|
+
|
|
102
|
+
def test_a_refusal_is_an_answer_not_an_exception(self):
|
|
103
|
+
text = type(self).request_fix("http://localhost:3001/admin", "reach inside")
|
|
104
|
+
self.assertIn("DoubleOh refused (400)", text)
|
|
105
|
+
self.assertIn("not reachable", text)
|
|
106
|
+
|
|
107
|
+
def test_adapters_fail_with_an_installable_sentence(self):
|
|
108
|
+
# None of the frameworks are installed in this environment, which IS the test.
|
|
109
|
+
from doubleoh.langchain import doubleoh_tools as lc
|
|
110
|
+
from doubleoh.crewai import doubleoh_tools as cr
|
|
111
|
+
from doubleoh.llamaindex import doubleoh_tools as li
|
|
112
|
+
client = DoubleOh(api_key="oo_test_1", base_url="http://127.0.0.1:1")
|
|
113
|
+
for adapter, package in ((lc, "langchain-core"), (cr, "crewai"), (li, "llama-index-core")):
|
|
114
|
+
with self.assertRaises(ImportError) as caught:
|
|
115
|
+
adapter(client)
|
|
116
|
+
self.assertIn(f"pip install {package}", str(caught.exception))
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
if __name__ == "__main__":
|
|
120
|
+
unittest.main()
|