hum-cli 0.2.63__py3-none-any.whl → 0.2.65__py3-none-any.whl
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.
- hum/__init__.py +1 -1
- hum/client.py +12 -9
- hum/core.py +31 -1
- hum/plain.py +10 -0
- hum/runtime/agent_files.py +6 -3
- hum/runtime/background.py +156 -26
- hum/runtime/config.py +13 -3
- hum/runtime/drivers.py +79 -36
- hum/runtime/llm.py +104 -43
- hum/runtime/tools.py +126 -19
- hum/tui/cli.mjs +49 -35
- {hum_cli-0.2.63.dist-info → hum_cli-0.2.65.dist-info}/METADATA +1 -1
- {hum_cli-0.2.63.dist-info → hum_cli-0.2.65.dist-info}/RECORD +16 -16
- {hum_cli-0.2.63.dist-info → hum_cli-0.2.65.dist-info}/WHEEL +0 -0
- {hum_cli-0.2.63.dist-info → hum_cli-0.2.65.dist-info}/entry_points.txt +0 -0
- {hum_cli-0.2.63.dist-info → hum_cli-0.2.65.dist-info}/licenses/LICENSE +0 -0
hum/__init__.py
CHANGED
|
@@ -1,2 +1,2 @@
|
|
|
1
1
|
"""Hum — an agent harness built as an RL environment that humans can drive."""
|
|
2
|
-
__version__ = "0.2.
|
|
2
|
+
__version__ = "0.2.65"
|
hum/client.py
CHANGED
|
@@ -183,11 +183,14 @@ class VisibleTools(ToolRegistry):
|
|
|
183
183
|
return results
|
|
184
184
|
|
|
185
185
|
|
|
186
|
-
def retry_line(kind: str,
|
|
187
|
-
"""``
|
|
188
|
-
|
|
189
|
-
|
|
190
|
-
|
|
186
|
+
def retry_line(kind: str, wait_s: float, attempt: int, of: int) -> str:
|
|
187
|
+
"""``Retrying in 30s · attempt 2/10 · capacity``, as Claude Code says it."""
|
|
188
|
+
return f"Retrying in {wait_s:.0f}s · attempt {attempt}/{of} · {kind}"
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
def waiting_line(s: float) -> str:
|
|
192
|
+
"""An attempt that has shown nothing for ``s`` seconds."""
|
|
193
|
+
return f"still waiting for the model ({s:.0f}s)"
|
|
191
194
|
|
|
192
195
|
|
|
193
196
|
class VisiblePolicy(PolicyDriver):
|
|
@@ -198,14 +201,13 @@ class VisiblePolicy(PolicyDriver):
|
|
|
198
201
|
**({"retries": retries} if retries is not None else {}))
|
|
199
202
|
self.ui = ui
|
|
200
203
|
self.on_retry = self._retrying
|
|
204
|
+
self.on_waiting = lambda s: ui.set_phase(waiting_line(s))
|
|
201
205
|
if hasattr(llm, "on_notice"):
|
|
202
206
|
llm.on_notice = ui.warn # our proxy's word on the person's credits, once
|
|
203
207
|
|
|
204
208
|
def _retrying(self, attempt: int, e: BaseException, delay: float) -> None:
|
|
205
|
-
"""The person sees the
|
|
206
|
-
|
|
207
|
-
self.ui.retry(attempt, (k := failure_of(e).kind), delay, retry_line(k, getattr(self.llm, "model", None),
|
|
208
|
-
delay, attempt, since))
|
|
209
|
+
"""The person sees the wait before the next attempt, which attempt of how many, and the failure's kind."""
|
|
210
|
+
self.ui.retry(attempt, (k := failure_of(e).kind), delay, retry_line(k, delay, attempt, self.retries))
|
|
209
211
|
|
|
210
212
|
async def next(self, messages, tools): # type: ignore[override]
|
|
211
213
|
self.ui.turn_started()
|
|
@@ -550,6 +552,7 @@ class Client:
|
|
|
550
552
|
return MCPToolset(m.name, url=m.url, command=m.command, args=m.args, headers=server_headers(m),
|
|
551
553
|
env_pass=self.cfg.sandbox_env_pass, timeout_s=m.timeout_s,
|
|
552
554
|
connect_timeout_s=m.connect_timeout_s, background_s=m.background_s,
|
|
555
|
+
task_timeout_s=m.task_timeout_s,
|
|
553
556
|
env_extra=self._env_for(m),
|
|
554
557
|
meta=self.call_meta if source == "deployment" and not by_identity else None,
|
|
555
558
|
# a server chosen for this session by its author (an agent file's, a program's) is given
|
hum/core.py
CHANGED
|
@@ -52,6 +52,10 @@ Front-end → core (commands)
|
|
|
52
52
|
{"cmd": "attach", "path": "…"} an image file the person named → image {…}
|
|
53
53
|
{"cmd": "shell", "command": "…"} the person runs a command themselves (!cmd)
|
|
54
54
|
{"cmd": "interrupt"} stop the current turn; what it had so far is kept
|
|
55
|
+
{"cmd": "background", "cancel"?: "<call id>"} → info: the calls still out past their turns (a tool server's task,
|
|
56
|
+
a call past background_s); with cancel, that one is stopped (a task is
|
|
57
|
+
cancelled on its server) and the model is woken with its end. The
|
|
58
|
+
one path that cancels a task: a watch's own bound never does
|
|
55
59
|
{"cmd": "model", "name": "…", "default"?: true} switch model for the rest of the session; default = also the
|
|
56
60
|
default for new sessions; no name → models {items, via}, the picker
|
|
57
61
|
{"cmd": "decide", "decision": "accept"|"reject", "note": "…", "remember"?: "session"|"project"} answer a proposal;
|
|
@@ -673,7 +677,7 @@ class WireUI(UI):
|
|
|
673
677
|
self._text = []
|
|
674
678
|
self._announced.clear()
|
|
675
679
|
self._fold_thought()
|
|
676
|
-
self.set_phase(
|
|
680
|
+
self.set_phase(text)
|
|
677
681
|
self.wire.send("retry", attempt=attempt, kind=kind, wait_s=wait_s, text=text)
|
|
678
682
|
|
|
679
683
|
def error(self, msg: str) -> None:
|
|
@@ -1279,6 +1283,16 @@ class Core:
|
|
|
1279
1283
|
content = note or ""
|
|
1280
1284
|
extra: dict[str, Any] = {"event": "task_note", **keep}
|
|
1281
1285
|
self.ui.info(f"{name}: {' '.join((note or '').split())[:200]}")
|
|
1286
|
+
elif about.get("unwatched"):
|
|
1287
|
+
# the call did not end: Hum let go of a task that still stands on its server. The record keeps it out
|
|
1288
|
+
# (no task_done), so a session opened again watches it again
|
|
1289
|
+
took = duration(float(about.get("elapsed_s") or 0.0))
|
|
1290
|
+
content = (f"[{name} is no longer watched after {took}; its task still stands on its server. Nothing "
|
|
1291
|
+
f"here is from the person.]\n{result.content}")
|
|
1292
|
+
extra = {"event": "task_unwatched", **keep, "elapsed_s": about.get("elapsed_s"),
|
|
1293
|
+
"result": result.model_dump(mode="json")}
|
|
1294
|
+
self.ui.info(f"{name} no longer watched · {took} · task {about.get('task_id') or '?'} still on "
|
|
1295
|
+
f"{about.get('server') or 'its server'}")
|
|
1282
1296
|
else:
|
|
1283
1297
|
how = "finished" if result.ok else "failed"
|
|
1284
1298
|
took = duration(float(about.get("elapsed_s") or 0.0))
|
|
@@ -1627,6 +1641,21 @@ class Core:
|
|
|
1627
1641
|
async def _cmd_interrupt(self, cmd: dict[str, Any], w: Writer | None) -> None:
|
|
1628
1642
|
await self.interrupt()
|
|
1629
1643
|
|
|
1644
|
+
async def _cmd_background(self, cmd: dict[str, Any], w: Writer | None) -> None:
|
|
1645
|
+
which = str(cmd.get("cancel") or "").strip()
|
|
1646
|
+
if which:
|
|
1647
|
+
said = self.background.cancel(which, "a person cancelled it")
|
|
1648
|
+
if said == "stopping":
|
|
1649
|
+
self.ui.info(f"stopping {which}: when it has stopped, its end shows here and reaches the model")
|
|
1650
|
+
elif said == "ended":
|
|
1651
|
+
self.ui.info(f"{which} had already ended: its result reaches the model, not a stop")
|
|
1652
|
+
else:
|
|
1653
|
+
self.ui.warn(f"no call {which} is out: /background lists the ones that are")
|
|
1654
|
+
return
|
|
1655
|
+
out = self.background.pending
|
|
1656
|
+
self.ui.info("\n".join(f"{p['call_id']} {p['name']}" + (f" task {p['task_id']}" if p.get("task_id") else "")
|
|
1657
|
+
+ f" {p['elapsed_s']:.0f}s" for p in out) if out else "no calls are out")
|
|
1658
|
+
|
|
1630
1659
|
async def _cmd_model(self, cmd: dict[str, Any], w: Writer | None) -> None:
|
|
1631
1660
|
name = str(cmd.get("name", "")).strip()
|
|
1632
1661
|
if name:
|
|
@@ -2392,6 +2421,7 @@ class Core:
|
|
|
2392
2421
|
"send": _cmd_send,
|
|
2393
2422
|
"shell": _cmd_shell,
|
|
2394
2423
|
"interrupt": _cmd_interrupt,
|
|
2424
|
+
"background": _cmd_background,
|
|
2395
2425
|
"model": _cmd_model,
|
|
2396
2426
|
"reasoning": _cmd_reasoning,
|
|
2397
2427
|
"decide": _cmd_decide,
|
hum/plain.py
CHANGED
|
@@ -38,6 +38,8 @@ HELP = """/help this
|
|
|
38
38
|
/goal [text] set the goal the session keeps working toward, exchange after exchange, until the model marks it
|
|
39
39
|
complete or blocked; /goal pause · resume · clear; /goal alone shows where it stands
|
|
40
40
|
/agent <n> … speak to one running sub-agent (it reads it at its next step); /agent <n> stop ends it
|
|
41
|
+
/background the tool calls still out past their turns (a server's task, a long call), by call id;
|
|
42
|
+
/background cancel <call id> stops one (a task is cancelled on its server)
|
|
41
43
|
/diff [what] what has changed since this session began (/diff last: the last exchange; /diff <path>: one file)
|
|
42
44
|
/rewind [n] the points you can go back to; /rewind <n> [files|chat|both] goes back to one
|
|
43
45
|
/accept [why] the work as it stands is what you wanted
|
|
@@ -525,6 +527,14 @@ class Plain:
|
|
|
525
527
|
send(cmd="agent", agent=int(n), stop=True)
|
|
526
528
|
else:
|
|
527
529
|
send(cmd="agent", agent=int(n), text=rest.strip())
|
|
530
|
+
elif cmd == "background":
|
|
531
|
+
verb, _, which = arg.partition(" ")
|
|
532
|
+
if not arg:
|
|
533
|
+
send(cmd="background")
|
|
534
|
+
elif verb == "cancel" and which.strip():
|
|
535
|
+
send(cmd="background", cancel=which.strip())
|
|
536
|
+
else:
|
|
537
|
+
self.ui.info("/background lists the calls still out; /background cancel <call id> stops one")
|
|
528
538
|
elif cmd == "goal":
|
|
529
539
|
send(cmd="goal", text=arg)
|
|
530
540
|
elif cmd == "diff":
|
hum/runtime/agent_files.py
CHANGED
|
@@ -32,7 +32,7 @@ from typing import Any
|
|
|
32
32
|
|
|
33
33
|
import yaml
|
|
34
34
|
|
|
35
|
-
from .config import MCP_BACKGROUND_S, MCP_CONNECT_TIMEOUT_S, MCP_TIMEOUT_S, MCPConfig
|
|
35
|
+
from .config import MCP_BACKGROUND_S, MCP_CONNECT_TIMEOUT_S, MCP_TASK_TIMEOUT_S, MCP_TIMEOUT_S, MCPConfig
|
|
36
36
|
from .skills import _FRONT, _NAME_OK, front_matter
|
|
37
37
|
|
|
38
38
|
# Hum's own, and nobody else's: another harness keeps its agents in its own folder, in its own format, and
|
|
@@ -174,7 +174,8 @@ def _server_words(m: MCPConfig) -> dict[str, Any]:
|
|
|
174
174
|
("timeout_s", m.timeout_s if m.timeout_s != MCP_TIMEOUT_S else None),
|
|
175
175
|
("connect_timeout_s", m.connect_timeout_s
|
|
176
176
|
if m.connect_timeout_s != MCP_CONNECT_TIMEOUT_S else None),
|
|
177
|
-
("background_s", m.background_s if m.background_s != MCP_BACKGROUND_S else None)
|
|
177
|
+
("background_s", m.background_s if m.background_s != MCP_BACKGROUND_S else None),
|
|
178
|
+
("task_timeout_s", m.task_timeout_s if m.task_timeout_s != MCP_TASK_TIMEOUT_S else None))
|
|
178
179
|
if v or (k == "background_s" and v is not None)}
|
|
179
180
|
|
|
180
181
|
|
|
@@ -191,6 +192,7 @@ def server_config(name: str, spec: Any, root: Path | None = None) -> MCPConfig |
|
|
|
191
192
|
command = str((root / str(command)).resolve())
|
|
192
193
|
headers = spec.get("headers") or {}
|
|
193
194
|
timeout, connect, background = spec.get("timeout_s"), spec.get("connect_timeout_s"), spec.get("background_s")
|
|
195
|
+
tasks = spec.get("task_timeout_s")
|
|
194
196
|
return MCPConfig(name=str(name).strip(), url=str(url) if url else None, command=str(command) if command else None,
|
|
195
197
|
args=[str(a) for a in spec.get("args") or []],
|
|
196
198
|
headers={str(k): str(v) for k, v in dict(headers).items()},
|
|
@@ -198,7 +200,8 @@ def server_config(name: str, spec: Any, root: Path | None = None) -> MCPConfig |
|
|
|
198
200
|
env={str(k): str(v) for k, v in dict(spec.get("env") or {}).items()},
|
|
199
201
|
timeout_s=float(timeout) if timeout else MCP_TIMEOUT_S,
|
|
200
202
|
connect_timeout_s=float(connect) if connect else MCP_CONNECT_TIMEOUT_S,
|
|
201
|
-
background_s=float(background) if background is not None else MCP_BACKGROUND_S
|
|
203
|
+
background_s=float(background) if background is not None else MCP_BACKGROUND_S,
|
|
204
|
+
task_timeout_s=float(tasks) if tasks else MCP_TASK_TIMEOUT_S)
|
|
202
205
|
|
|
203
206
|
|
|
204
207
|
def _front_servers(text: str, root: Path | None) -> list[MCPConfig]:
|
hum/runtime/background.py
CHANGED
|
@@ -90,33 +90,110 @@ class GetTaskRequest(mcp_types.Request[_TaskParams, Literal["tasks/get"]]):
|
|
|
90
90
|
name_param = "taskId" # the extension routes tasks/* by the task id in the Mcp-Name header
|
|
91
91
|
|
|
92
92
|
|
|
93
|
+
class CancelTaskRequest(mcp_types.Request[_TaskParams, Literal["tasks/cancel"]]):
|
|
94
|
+
method: Literal["tasks/cancel"] = "tasks/cancel"
|
|
95
|
+
params: _TaskParams
|
|
96
|
+
name_param = "taskId"
|
|
97
|
+
|
|
98
|
+
|
|
93
99
|
_TASK = TypeAdapter(Task)
|
|
100
|
+
_ANY = TypeAdapter(dict[str, Any])
|
|
101
|
+
POLL_TIMEOUT_S = 30.0 # one tasks/get: a poll the server does not answer in this long is asked again, not waited on
|
|
94
102
|
|
|
95
103
|
|
|
96
|
-
async def get_task(session: Any, task_id: str) -> Task:
|
|
104
|
+
async def get_task(session: Any, task_id: str, timeout_s: float | None = POLL_TIMEOUT_S) -> Task:
|
|
97
105
|
"""One poll: the task as the server has it now. Raises the SDK's MCPError for a task the server no longer
|
|
98
|
-
holds (its TTL ran out) or never had
|
|
99
|
-
return await session.send_request(GetTaskRequest(params=_TaskParams(task_id=task_id)), _TASK
|
|
100
|
-
|
|
101
|
-
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
"""
|
|
105
|
-
|
|
106
|
-
|
|
107
|
-
|
|
108
|
-
|
|
109
|
-
|
|
106
|
+
holds (its TTL ran out) or never had, and for a poll it did not answer within ``timeout_s``."""
|
|
107
|
+
return await session.send_request(GetTaskRequest(params=_TaskParams(task_id=task_id)), _TASK,
|
|
108
|
+
request_read_timeout_seconds=timeout_s)
|
|
109
|
+
|
|
110
|
+
|
|
111
|
+
async def cancel_task(session: Any, task_id: str, timeout_s: float | None = POLL_TIMEOUT_S) -> None:
|
|
112
|
+
"""Ask the server to cancel a task. Cancellation is eventually consistent on its side; the ack is all this
|
|
113
|
+
waits for."""
|
|
114
|
+
await session.send_request(CancelTaskRequest(params=_TaskParams(task_id=task_id)), _ANY,
|
|
115
|
+
request_read_timeout_seconds=timeout_s)
|
|
116
|
+
|
|
117
|
+
|
|
118
|
+
class CallCancelled(Exception):
|
|
119
|
+
"""A call still out that a person stopped (Background.cancel), in the words the model reads."""
|
|
120
|
+
|
|
121
|
+
|
|
122
|
+
class TaskUnwatched(Exception):
|
|
123
|
+
"""A watch that ended with its task still standing on its server: Hum stopped following it (its ceiling
|
|
124
|
+
passed, or the server could not be reached), it did not end. The words are what the model reads; the record
|
|
125
|
+
keeps the call out, so a session opened again watches the task again."""
|
|
126
|
+
|
|
127
|
+
|
|
128
|
+
class TaskTimedOut(Exception):
|
|
129
|
+
"""A task Hum followed past its client's ceiling (``task_timeout_s``), the task as it last stood."""
|
|
130
|
+
|
|
131
|
+
def __init__(self, task: Task, ceiling_s: float):
|
|
132
|
+
super().__init__(f"task {task.task_id} was still {task.status} after {ceiling_s:.0f}s")
|
|
133
|
+
self.task, self.ceiling_s = task, ceiling_s
|
|
134
|
+
|
|
135
|
+
|
|
136
|
+
class TaskUnreachable(Exception):
|
|
137
|
+
"""Polls of a task that reached no server, as many in a row as the watch allows: the task's state is
|
|
138
|
+
unknown, not ended."""
|
|
139
|
+
|
|
140
|
+
def __init__(self, task_id: str, tries: int, cause: BaseException):
|
|
141
|
+
super().__init__(f"task {task_id}: {tries} polls in a row reached no server ({cause})")
|
|
142
|
+
self.task_id, self.tries, self.cause = task_id, tries, cause
|
|
143
|
+
|
|
144
|
+
|
|
145
|
+
def created_at(task: Task) -> float | None:
|
|
146
|
+
"""When the server says the task was made, as wall-clock seconds; None when it does not say legibly. A
|
|
147
|
+
timestamp with no offset is read as UTC, as the extension's own are written."""
|
|
148
|
+
if not task.created_at:
|
|
149
|
+
return None
|
|
150
|
+
from datetime import datetime, timezone
|
|
151
|
+
try:
|
|
152
|
+
when = datetime.fromisoformat(task.created_at.replace("Z", "+00:00"))
|
|
153
|
+
except ValueError:
|
|
154
|
+
return None
|
|
155
|
+
if when.tzinfo is None:
|
|
156
|
+
when = when.replace(tzinfo=timezone.utc)
|
|
157
|
+
return when.timestamp()
|
|
158
|
+
|
|
159
|
+
|
|
160
|
+
async def poll(fetch: Callable[[str], Awaitable[Task]], task: Task, *,
|
|
161
|
+
on_status: Callable[[Task], Any] | None = None, now: bool = False,
|
|
162
|
+
ceiling_s: float | None = None) -> Task:
|
|
163
|
+
"""Follow one task to its end: sleep the interval the server names, ``fetch`` it again, until a terminal
|
|
164
|
+
status. ``now`` asks before the first sleep (a task known only by its id, from the record). ``on_status``
|
|
165
|
+
hears every change of status on the way (working to input_required, and back).
|
|
166
|
+
|
|
167
|
+
The task's ``ttlMs`` is how long the server may keep it, not a deadline for the client: a task that outlives
|
|
168
|
+
it is followed while the server still answers for it, and one the server has dropped ends the watch with the
|
|
169
|
+
server's own error. The client's own bound is ``ceiling_s``: how long this watch follows the task, counted
|
|
170
|
+
from when it began (a session opened again watches for a fresh while). Past it, a sleep or a fetch still out
|
|
171
|
+
included, the watch raises TaskTimedOut with the task as it last stood. It only ends the watch: the task
|
|
172
|
+
stands on its server."""
|
|
173
|
+
began = time.monotonic()
|
|
174
|
+
|
|
175
|
+
def left() -> float:
|
|
176
|
+
return began + ceiling_s - time.monotonic() if ceiling_s is not None else float("inf")
|
|
177
|
+
|
|
110
178
|
while not task.done:
|
|
111
|
-
if
|
|
112
|
-
|
|
113
|
-
"status_message": f"the task's time to live ({task.ttl_ms} ms) ran out "
|
|
114
|
-
f"with it still {task.status}"})
|
|
179
|
+
if left() <= 0:
|
|
180
|
+
raise TaskTimedOut(task, ceiling_s or 0.0)
|
|
115
181
|
if now:
|
|
116
182
|
now = False
|
|
117
183
|
else:
|
|
118
|
-
await asyncio.sleep(task.interval_s())
|
|
119
|
-
|
|
184
|
+
await asyncio.sleep(max(0.0, min(task.interval_s(), left())))
|
|
185
|
+
if left() <= 0:
|
|
186
|
+
raise TaskTimedOut(task, ceiling_s or 0.0)
|
|
187
|
+
if ceiling_s is None:
|
|
188
|
+
fresh = await fetch(task.task_id)
|
|
189
|
+
else:
|
|
190
|
+
try:
|
|
191
|
+
async with asyncio.timeout(left()) as bound:
|
|
192
|
+
fresh = await fetch(task.task_id)
|
|
193
|
+
except TimeoutError:
|
|
194
|
+
if bound.expired():
|
|
195
|
+
raise TaskTimedOut(task, ceiling_s) from None
|
|
196
|
+
raise
|
|
120
197
|
if fresh.status != task.status and on_status is not None:
|
|
121
198
|
on_status(fresh)
|
|
122
199
|
task = fresh
|
|
@@ -132,7 +209,7 @@ def task_extension() -> tuple[dict[str, dict[str, Any]], dict[str, list[Any]]]:
|
|
|
132
209
|
from mcp.types import CallToolResult
|
|
133
210
|
|
|
134
211
|
async def resolve(handle: TaskHandle, ctx: ClaimContext) -> CallToolResult:
|
|
135
|
-
task = await poll(ctx.session, handle.task())
|
|
212
|
+
task = await poll(lambda tid: get_task(ctx.session, tid), handle.task())
|
|
136
213
|
return call_result(task)
|
|
137
214
|
|
|
138
215
|
claim = ResultClaim(result_type="task", model=TaskHandle, resolve=resolve)
|
|
@@ -203,6 +280,9 @@ class Deferred:
|
|
|
203
280
|
outcome: Callable[[Callable[[str], None]], Awaitable[Any]]
|
|
204
281
|
task_id: str | None = None
|
|
205
282
|
waited_s: float = 0.0 # how long the turn waited before the call went on without it
|
|
283
|
+
# how to stop the call where it runs, for a person who no longer wants it: a task is cancelled on its server
|
|
284
|
+
# (tasks/cancel), a plain call's request is given up on. None: there is nothing to stop but the watch
|
|
285
|
+
cancel: Callable[[], Awaitable[None]] | None = None
|
|
206
286
|
|
|
207
287
|
def about(self) -> dict[str, Any]:
|
|
208
288
|
"""The facts the record keeps about the call that went on: enough to pick the watch up again."""
|
|
@@ -218,6 +298,9 @@ class Watch:
|
|
|
218
298
|
deferred: Deferred
|
|
219
299
|
started: float = field(default_factory=time.monotonic)
|
|
220
300
|
task: asyncio.Task | None = None
|
|
301
|
+
stopped: str | None = None # why a person stopped this call (Background.cancel); None while it runs
|
|
302
|
+
stop: asyncio.Event = field(default_factory=asyncio.Event)
|
|
303
|
+
outcome: asyncio.Future | None = None # the call's own end, as it runs
|
|
221
304
|
|
|
222
305
|
def facts(self) -> dict[str, Any]:
|
|
223
306
|
return {"call_id": self.call.id, "name": self.call.name, **self.deferred.about(),
|
|
@@ -263,19 +346,66 @@ class Background:
|
|
|
263
346
|
if self.deliver is not None:
|
|
264
347
|
self.deliver(w.facts(), None, text)
|
|
265
348
|
try:
|
|
349
|
+
outcome = w.outcome = asyncio.ensure_future(w.deferred.outcome(say))
|
|
350
|
+
stop = asyncio.ensure_future(w.stop.wait())
|
|
266
351
|
try:
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
352
|
+
await asyncio.wait((outcome, stop), return_when=asyncio.FIRST_COMPLETED)
|
|
353
|
+
finally:
|
|
354
|
+
stop.cancel()
|
|
355
|
+
if not outcome.done():
|
|
356
|
+
outcome.cancel()
|
|
357
|
+
with contextlib.suppress(asyncio.CancelledError, Exception):
|
|
358
|
+
await outcome
|
|
359
|
+
if not outcome.cancelled() and outcome.exception() is None:
|
|
360
|
+
raw = outcome.result()
|
|
361
|
+
elif w.stopped is not None:
|
|
362
|
+
raw = CallCancelled(await self._stop_at_source(w))
|
|
363
|
+
elif outcome.cancelled():
|
|
364
|
+
raise asyncio.CancelledError()
|
|
365
|
+
else: # the failure goes where the text would have: finish renders it
|
|
366
|
+
raw = outcome.exception()
|
|
272
367
|
result = await finish(raw, w.started)
|
|
273
368
|
except asyncio.CancelledError:
|
|
274
369
|
self._watches.pop(w.call.id, None)
|
|
275
370
|
raise
|
|
276
371
|
self._watches.pop(w.call.id, None)
|
|
277
372
|
if self.deliver is not None:
|
|
278
|
-
|
|
373
|
+
# a watch that let go of a task still standing says so: the record keeps the call out for the next
|
|
374
|
+
# opening to watch again, where an ended call is answered
|
|
375
|
+
self.deliver({**w.facts(), **({"unwatched": True} if isinstance(raw, TaskUnwatched) else {})},
|
|
376
|
+
result, None)
|
|
377
|
+
|
|
378
|
+
def cancel(self, call_id: str, why: str = "a person cancelled it") -> Literal["stopping", "ended"] | None:
|
|
379
|
+
"""Stop one call still out, by the id of the call that started it: a task is cancelled on its server, a
|
|
380
|
+
plain call is given up on, and the model is woken with the call's end in those words. ``stopping`` when
|
|
381
|
+
the stop goes ahead; ``ended`` when the call had already ended and its result is on its way (it is
|
|
382
|
+
delivered, not stopped); None when no call by that id is out. This is the one path that cancels a task
|
|
383
|
+
on its server: a person's or the model's word, never a watch's own bound."""
|
|
384
|
+
w = self._watches.get(call_id)
|
|
385
|
+
if w is None or w.task is None or w.task.done():
|
|
386
|
+
return None
|
|
387
|
+
if w.stopped is not None:
|
|
388
|
+
return "stopping"
|
|
389
|
+
if w.outcome is not None and w.outcome.done():
|
|
390
|
+
return "ended"
|
|
391
|
+
w.stopped = why
|
|
392
|
+
w.stop.set()
|
|
393
|
+
return "stopping"
|
|
394
|
+
|
|
395
|
+
@staticmethod
|
|
396
|
+
async def _stop_at_source(w: Watch) -> str:
|
|
397
|
+
"""What stopping the call did where it runs, in the words the model reads."""
|
|
398
|
+
what = f"{w.call.name}" + (f" (task {w.deferred.task_id})" if w.deferred.task_id else "")
|
|
399
|
+
if w.deferred.cancel is None:
|
|
400
|
+
return f"{what} was stopped: {w.stopped}. Hum no longer waits for it."
|
|
401
|
+
try:
|
|
402
|
+
await w.deferred.cancel()
|
|
403
|
+
except Exception as e:
|
|
404
|
+
return (f"{what} was stopped: {w.stopped}. Hum asked {w.deferred.server} to cancel it and it did not "
|
|
405
|
+
f"say it would ({type(e).__name__}: {e}); it may still run there.")
|
|
406
|
+
if w.deferred.kind == "task":
|
|
407
|
+
return f"{what} was stopped: {w.stopped}. Hum asked {w.deferred.server} to cancel the task."
|
|
408
|
+
return f"{what} was stopped: {w.stopped}. Hum gave up its request; the server may still finish it."
|
|
279
409
|
|
|
280
410
|
async def close(self) -> None:
|
|
281
411
|
"""Stop watching: the tasks stand on their servers, and a session opened again finds them by the ids
|
hum/runtime/config.py
CHANGED
|
@@ -18,6 +18,11 @@ MCP_TIMEOUT_S = 900.0
|
|
|
18
18
|
MCP_CONNECT_TIMEOUT_S = 30.0
|
|
19
19
|
# how long a call holds the turn before it goes on in the background and its result comes back as a wake
|
|
20
20
|
MCP_BACKGROUND_S = 120.0
|
|
21
|
+
# how long one watch follows a tool server's task before it lets go: the client's own bound, whatever time to live the
|
|
22
|
+
# server names, so a task that never ends does not hold a watch forever. Letting go never cancels the task: it goes on
|
|
23
|
+
# on its server, the model is told its id, and a session opened again watches it again. Generous: a Harwell run may
|
|
24
|
+
# take an hour, and a task is a call that was always going to be long
|
|
25
|
+
MCP_TASK_TIMEOUT_S = 7200.0
|
|
21
26
|
|
|
22
27
|
|
|
23
28
|
@dataclass
|
|
@@ -33,6 +38,8 @@ class MCPConfig:
|
|
|
33
38
|
# [[mcp]] background_s: seconds a call holds the turn before it goes on in the background; 0 holds the turn
|
|
34
39
|
# to the deadline
|
|
35
40
|
background_s: float = MCP_BACKGROUND_S
|
|
41
|
+
# [[mcp]] task_timeout_s: seconds one watch follows one of this server's tasks before it lets go (never cancels it)
|
|
42
|
+
task_timeout_s: float = MCP_TASK_TIMEOUT_S
|
|
36
43
|
env: dict[str, str] = field(default_factory=dict) # a plugin's server: what its process is given
|
|
37
44
|
plugin: str = "" # the plugin that brought it, when one did
|
|
38
45
|
of_repository: bool = False # the repository's word named it, not the person's (trust.claim)
|
|
@@ -65,9 +72,11 @@ class Config:
|
|
|
65
72
|
api_key: str | None = None
|
|
66
73
|
reasoning_effort: str | None = None
|
|
67
74
|
temperature: float | None = None
|
|
68
|
-
idle_timeout_s:
|
|
75
|
+
# [model] idle_timeout_s: once an attempt is answered, the longest silence between stream events (keepalives count)
|
|
76
|
+
# before it is cut and retried; the gateway keeps a long think alive (hum.runtime.llm.STREAM_IDLE_S)
|
|
77
|
+
idle_timeout_s: float = 300.0
|
|
69
78
|
# [model] retries: requests tried again after a throttle, an upstream failure or a dropped connection
|
|
70
|
-
retries: int = 10 # a
|
|
79
|
+
retries: int = 10 # the most retries of a turn; fewer by kind and wait (hum.runtime.drivers)
|
|
71
80
|
mcp: list[MCPConfig] = field(default_factory=list)
|
|
72
81
|
lsp: list[LSPConfig] = field(default_factory=list) # [[lsp]]: language servers, beside the ones found on PATH
|
|
73
82
|
gate: str = "off"
|
|
@@ -169,6 +178,7 @@ def _server(s: dict[str, Any], of_repository: bool = False) -> MCPConfig:
|
|
|
169
178
|
timeout_s=float(s.get("timeout_s") or MCP_TIMEOUT_S),
|
|
170
179
|
connect_timeout_s=float(s.get("connect_timeout_s") or MCP_CONNECT_TIMEOUT_S),
|
|
171
180
|
background_s=float(s["background_s"]) if s.get("background_s") is not None else MCP_BACKGROUND_S,
|
|
181
|
+
task_timeout_s=float(s.get("task_timeout_s") or MCP_TASK_TIMEOUT_S),
|
|
172
182
|
env={str(k): str(v) for k, v in (s.get("env") or {}).items()},
|
|
173
183
|
of_repository=of_repository)
|
|
174
184
|
|
|
@@ -293,7 +303,7 @@ def load(path: Path | None = None) -> Config:
|
|
|
293
303
|
# server's default
|
|
294
304
|
reasoning_effort=_env("HUM_REASONING_EFFORT") or m.get("reasoning_effort") or cred.get("reasoning_effort")
|
|
295
305
|
or None,
|
|
296
|
-
temperature=m.get("temperature"), idle_timeout_s=float(m.get("idle_timeout_s",
|
|
306
|
+
temperature=m.get("temperature"), idle_timeout_s=float(m.get("idle_timeout_s", 300.0)),
|
|
297
307
|
retries=int(m.get("retries", 10)),
|
|
298
308
|
mcp=[_server(s) for s in d.get("mcp", []) if s.get("name")],
|
|
299
309
|
lsp=[LSPConfig(name=str(s["name"]), command=str(s["command"]), args=[str(x) for x in s.get("args", [])],
|