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.
@@ -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/
@@ -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.
@@ -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()