puffo-agent 0.7.2__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.
- puffo_agent/__init__.py +0 -0
- puffo_agent/agent/__init__.py +0 -0
- puffo_agent/agent/_logging.py +18 -0
- puffo_agent/agent/_time.py +18 -0
- puffo_agent/agent/adapters/__init__.py +12 -0
- puffo_agent/agent/adapters/base.py +238 -0
- puffo_agent/agent/adapters/chat_only.py +30 -0
- puffo_agent/agent/adapters/cli_session.py +636 -0
- puffo_agent/agent/adapters/docker_cli.py +1220 -0
- puffo_agent/agent/adapters/local_cli.py +526 -0
- puffo_agent/agent/adapters/sdk.py +195 -0
- puffo_agent/agent/core.py +247 -0
- puffo_agent/agent/events.py +52 -0
- puffo_agent/agent/file_browser.py +106 -0
- puffo_agent/agent/harness/__init__.py +40 -0
- puffo_agent/agent/harness/base.py +53 -0
- puffo_agent/agent/harness/claude_code.py +22 -0
- puffo_agent/agent/harness/gemini_cli.py +18 -0
- puffo_agent/agent/harness/hermes.py +32 -0
- puffo_agent/agent/memory.py +37 -0
- puffo_agent/agent/message_store.py +254 -0
- puffo_agent/agent/providers/__init__.py +0 -0
- puffo_agent/agent/providers/anthropic_provider.py +19 -0
- puffo_agent/agent/providers/openai_provider.py +20 -0
- puffo_agent/agent/puffo_core_client.py +1333 -0
- puffo_agent/agent/shared_content.py +772 -0
- puffo_agent/agent/skills/__init__.py +0 -0
- puffo_agent/agent/skills_loader.py +41 -0
- puffo_agent/agent/status_reporter.py +128 -0
- puffo_agent/crypto/__init__.py +0 -0
- puffo_agent/crypto/attachments.py +100 -0
- puffo_agent/crypto/canonical.py +83 -0
- puffo_agent/crypto/certs.py +71 -0
- puffo_agent/crypto/encoding.py +17 -0
- puffo_agent/crypto/fingerprint.py +25 -0
- puffo_agent/crypto/http_auth.py +118 -0
- puffo_agent/crypto/http_client.py +194 -0
- puffo_agent/crypto/keystore.py +156 -0
- puffo_agent/crypto/message.py +272 -0
- puffo_agent/crypto/primitives.py +135 -0
- puffo_agent/crypto/v2_aad.py +98 -0
- puffo_agent/crypto/ws_client.py +193 -0
- puffo_agent/hooks/__init__.py +0 -0
- puffo_agent/hooks/permission.py +268 -0
- puffo_agent/mcp/__init__.py +5 -0
- puffo_agent/mcp/config.py +159 -0
- puffo_agent/mcp/data_client.py +160 -0
- puffo_agent/mcp/host_tools.py +254 -0
- puffo_agent/mcp/puffo_core_server.py +230 -0
- puffo_agent/mcp/puffo_core_tools.py +533 -0
- puffo_agent/portal/__init__.py +0 -0
- puffo_agent/portal/api/__init__.py +11 -0
- puffo_agent/portal/api/auth.py +138 -0
- puffo_agent/portal/api/certs.py +151 -0
- puffo_agent/portal/api/cors.py +84 -0
- puffo_agent/portal/api/handlers.py +1196 -0
- puffo_agent/portal/api/ownership.py +53 -0
- puffo_agent/portal/api/pairing.py +80 -0
- puffo_agent/portal/api/server.py +95 -0
- puffo_agent/portal/cli.py +1194 -0
- puffo_agent/portal/daemon.py +351 -0
- puffo_agent/portal/data_service.py +229 -0
- puffo_agent/portal/runtime_matrix.py +225 -0
- puffo_agent/portal/state.py +1005 -0
- puffo_agent/portal/worker.py +799 -0
- puffo_agent-0.7.2.dist-info/METADATA +346 -0
- puffo_agent-0.7.2.dist-info/RECORD +71 -0
- puffo_agent-0.7.2.dist-info/WHEEL +5 -0
- puffo_agent-0.7.2.dist-info/entry_points.txt +2 -0
- puffo_agent-0.7.2.dist-info/licenses/LICENSE +21 -0
- puffo_agent-0.7.2.dist-info/top_level.txt +1 -0
puffo_agent/__init__.py
ADDED
|
File without changes
|
|
File without changes
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Per-agent logger that prefixes each record with ``agent <id>:``
|
|
2
|
+
so log lines from a multi-agent daemon stay attributable."""
|
|
3
|
+
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import logging
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
class _AgentLogAdapter(logging.LoggerAdapter):
|
|
10
|
+
def process(self, msg, kwargs):
|
|
11
|
+
agent_id = self.extra.get("agent_id") if self.extra else ""
|
|
12
|
+
if agent_id:
|
|
13
|
+
return f"agent {agent_id}: {msg}", kwargs
|
|
14
|
+
return msg, kwargs
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def agent_logger(name: str, agent_id: str) -> logging.LoggerAdapter:
|
|
18
|
+
return _AgentLogAdapter(logging.getLogger(name), {"agent_id": agent_id})
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
"""Small time-format helpers shared across the agent layer."""
|
|
2
|
+
|
|
3
|
+
from datetime import datetime, timezone
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
def ms_to_iso(ms: int) -> str:
|
|
7
|
+
"""Render a ms-since-epoch timestamp as ISO 8601 in UTC.
|
|
8
|
+
Empty string when ``ms`` is 0 / missing — callers drop the
|
|
9
|
+
field rather than emitting an empty timestamp.
|
|
10
|
+
"""
|
|
11
|
+
if not ms:
|
|
12
|
+
return ""
|
|
13
|
+
try:
|
|
14
|
+
return datetime.fromtimestamp(
|
|
15
|
+
ms / 1000, tz=timezone.utc,
|
|
16
|
+
).isoformat(timespec="seconds")
|
|
17
|
+
except (ValueError, OSError):
|
|
18
|
+
return ""
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
"""Runtime adapters: translation layers between the portal shell
|
|
2
|
+
and an external agent runtime (Anthropic/OpenAI Messages API, the
|
|
3
|
+
``claude-agent-sdk`` package, or the ``claude`` CLI binary).
|
|
4
|
+
|
|
5
|
+
Adapters configure the runtime, forward its output, and manage its
|
|
6
|
+
lifecycle; they do not implement tools or run the agentic loop
|
|
7
|
+
themselves. See ``base.py`` for the interface.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
from .base import Adapter, TurnContext, TurnResult
|
|
11
|
+
|
|
12
|
+
__all__ = ["Adapter", "TurnContext", "TurnResult"]
|
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
"""Adapter interface.
|
|
2
|
+
|
|
3
|
+
Adapters translate ``TurnContext`` into a runtime-native invocation,
|
|
4
|
+
forward output back as a ``TurnResult``, and manage the runtime
|
|
5
|
+
instance's lifecycle. The runtime owns the agentic loop and tool
|
|
6
|
+
catalog.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from __future__ import annotations
|
|
10
|
+
|
|
11
|
+
import asyncio
|
|
12
|
+
import logging
|
|
13
|
+
from abc import ABC, abstractmethod
|
|
14
|
+
from dataclasses import dataclass, field
|
|
15
|
+
from typing import Awaitable, Callable, Optional
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
ProgressCallback = Callable[[str], Awaitable[None]]
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
# Refresh when fewer than this many seconds remain on the access
|
|
25
|
+
# token. Anthropic's OAuth endpoint refuses to rotate a token that's
|
|
26
|
+
# more than ~10 min from expiry (it returns the existing token
|
|
27
|
+
# unchanged), so 5 min lands inside the accept window while still
|
|
28
|
+
# giving the next worker tick room to retry.
|
|
29
|
+
CREDENTIAL_REFRESH_BEFORE_EXPIRY_SECONDS = 5 * 60
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Daemon-wide mutex across every Adapter instance. OAuth uses rotating
|
|
33
|
+
# refresh tokens — concurrent refreshes race and one gets
|
|
34
|
+
# ``invalid_grant``. The first agent to grab the lock refreshes; late
|
|
35
|
+
# arrivals SKIP (don't queue) and pick up the new file on their next
|
|
36
|
+
# tick.
|
|
37
|
+
_REFRESH_LOCK = asyncio.Lock()
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
@dataclass
|
|
41
|
+
class TurnContext:
|
|
42
|
+
"""One turn of input. ``workspace_dir`` / ``claude_dir`` /
|
|
43
|
+
``memory_dir`` are absolute paths the adapter may bind-mount or
|
|
44
|
+
pass to its runtime; chat-only adapters ignore them.
|
|
45
|
+
"""
|
|
46
|
+
system_prompt: str
|
|
47
|
+
messages: list[dict]
|
|
48
|
+
workspace_dir: str = ""
|
|
49
|
+
claude_dir: str = ""
|
|
50
|
+
memory_dir: str = ""
|
|
51
|
+
on_progress: Optional[ProgressCallback] = None
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class TurnResult:
|
|
56
|
+
"""One turn of output. ``reply == ""`` or ``"[SILENT]"`` means
|
|
57
|
+
"don't post" — the shell maps both to no-op.
|
|
58
|
+
"""
|
|
59
|
+
reply: str
|
|
60
|
+
input_tokens: int = 0
|
|
61
|
+
output_tokens: int = 0
|
|
62
|
+
tool_calls: int = 0
|
|
63
|
+
metadata: dict = field(default_factory=dict)
|
|
64
|
+
|
|
65
|
+
|
|
66
|
+
class Adapter(ABC):
|
|
67
|
+
"""Base class for all runtime adapters."""
|
|
68
|
+
|
|
69
|
+
# Health from the most recent refresh-ping/smoke-test probe.
|
|
70
|
+
# ``None`` = never checked, ``True`` = OK, ``False`` = auth failure
|
|
71
|
+
# (401 / authentication_error). Read by the worker to surface
|
|
72
|
+
# ``auth_failed`` in status output.
|
|
73
|
+
auth_healthy: bool | None = None
|
|
74
|
+
|
|
75
|
+
@abstractmethod
|
|
76
|
+
async def run_turn(self, ctx: TurnContext) -> TurnResult:
|
|
77
|
+
"""Execute one turn against the underlying runtime."""
|
|
78
|
+
|
|
79
|
+
async def warm(self, system_prompt: str) -> None:
|
|
80
|
+
"""Pre-spawn long-lived runtime state so the first turn
|
|
81
|
+
doesn't pay startup latency. Worker calls this after
|
|
82
|
+
construction if the agent has a persisted session to resume.
|
|
83
|
+
Default no-op for stateless adapters.
|
|
84
|
+
"""
|
|
85
|
+
return None
|
|
86
|
+
|
|
87
|
+
async def reload(self, new_system_prompt: str) -> None:
|
|
88
|
+
"""Drop cached runtime state so the next turn re-reads
|
|
89
|
+
CLAUDE.md / profile / memory from disk. Worker calls this
|
|
90
|
+
between turns after a ``reload_system_prompt`` MCP tool call.
|
|
91
|
+
CLI adapters close their long-lived claude subprocess (the
|
|
92
|
+
container stays up); SDK / chat-only adapters pass system
|
|
93
|
+
prompt per turn anyway, so the default no-op is correct.
|
|
94
|
+
"""
|
|
95
|
+
return None
|
|
96
|
+
|
|
97
|
+
async def refresh_ping(self) -> None:
|
|
98
|
+
"""Force an OAuth round-trip so Anthropic's rotating refresh
|
|
99
|
+
token gets exchanged before the access token dies. Guarded by
|
|
100
|
+
a daemon-wide mutex so concurrent agents don't dogpile the
|
|
101
|
+
endpoint — first wins, others skip. Subclass hooks:
|
|
102
|
+
``_credentials_expires_in_seconds`` (TTL probe) and
|
|
103
|
+
``_run_refresh_oneshot`` (actual refresh). SDK / chat-only
|
|
104
|
+
adapters short-circuit via the default ``None`` TTL.
|
|
105
|
+
"""
|
|
106
|
+
expires_in_before = self._credentials_expires_in_seconds()
|
|
107
|
+
if expires_in_before is None:
|
|
108
|
+
return
|
|
109
|
+
if expires_in_before > CREDENTIAL_REFRESH_BEFORE_EXPIRY_SECONDS:
|
|
110
|
+
logger.debug(
|
|
111
|
+
"credentials fresh (expires in %ds), skipping refresh ping",
|
|
112
|
+
expires_in_before,
|
|
113
|
+
)
|
|
114
|
+
return
|
|
115
|
+
|
|
116
|
+
# Don't queue behind an in-flight refresh — next tick will
|
|
117
|
+
# see the freshly-written file.
|
|
118
|
+
if _REFRESH_LOCK.locked():
|
|
119
|
+
logger.debug(
|
|
120
|
+
"another agent is refreshing; skipping this tick "
|
|
121
|
+
"(expires in %ds; next tick will see fresh file)",
|
|
122
|
+
expires_in_before,
|
|
123
|
+
)
|
|
124
|
+
return
|
|
125
|
+
|
|
126
|
+
async with _REFRESH_LOCK:
|
|
127
|
+
# Re-check after acquiring; another agent may have just
|
|
128
|
+
# finished refreshing.
|
|
129
|
+
expires_in_recheck = self._credentials_expires_in_seconds()
|
|
130
|
+
if expires_in_recheck is None:
|
|
131
|
+
logger.warning(
|
|
132
|
+
"refresh_ping: credentials file disappeared "
|
|
133
|
+
"between threshold check and lock acquire"
|
|
134
|
+
)
|
|
135
|
+
return
|
|
136
|
+
if expires_in_recheck > CREDENTIAL_REFRESH_BEFORE_EXPIRY_SECONDS:
|
|
137
|
+
logger.info(
|
|
138
|
+
"credentials refreshed by another agent "
|
|
139
|
+
"(expires in %ds); skipping", expires_in_recheck,
|
|
140
|
+
)
|
|
141
|
+
return
|
|
142
|
+
|
|
143
|
+
logger.info(
|
|
144
|
+
"credentials expire in %ds — running refresh ping",
|
|
145
|
+
expires_in_recheck,
|
|
146
|
+
)
|
|
147
|
+
try:
|
|
148
|
+
await self._run_refresh_oneshot()
|
|
149
|
+
except Exception as exc:
|
|
150
|
+
logger.warning("refresh_ping failed: %s", exc)
|
|
151
|
+
return
|
|
152
|
+
|
|
153
|
+
expires_in_after = self._credentials_expires_in_seconds()
|
|
154
|
+
if expires_in_after is None:
|
|
155
|
+
logger.warning(
|
|
156
|
+
"refresh_ping ran but credentials file is no "
|
|
157
|
+
"longer readable (was expiring in %ds)",
|
|
158
|
+
expires_in_recheck,
|
|
159
|
+
)
|
|
160
|
+
return
|
|
161
|
+
logger.info(
|
|
162
|
+
"credentials refreshed: expires in %ds (was %ds)",
|
|
163
|
+
expires_in_after, expires_in_recheck,
|
|
164
|
+
)
|
|
165
|
+
if expires_in_after <= expires_in_recheck:
|
|
166
|
+
logger.warning(
|
|
167
|
+
"refresh_ping ran but token expiry didn't advance "
|
|
168
|
+
"— claude may not be rewriting the credentials "
|
|
169
|
+
"file; check OAuth state"
|
|
170
|
+
)
|
|
171
|
+
|
|
172
|
+
def _credentials_expires_in_seconds(self) -> int | None:
|
|
173
|
+
"""Seconds until the OAuth access token expires (negative if
|
|
174
|
+
already past). ``None`` means "not OAuth" (SDK / chat-only)
|
|
175
|
+
or "file unreadable", both of which short-circuit
|
|
176
|
+
``refresh_ping``. Subclass hook.
|
|
177
|
+
"""
|
|
178
|
+
return None
|
|
179
|
+
|
|
180
|
+
async def _run_refresh_oneshot(self) -> None:
|
|
181
|
+
"""Spawn a short-lived claude invocation that forces an auth
|
|
182
|
+
round-trip and writes a refreshed token back to
|
|
183
|
+
``.credentials.json``. Must NOT reuse the long-lived session
|
|
184
|
+
— the credentials-write path only fires on process exit.
|
|
185
|
+
Subclass hook; default no-op for SDK / chat-only.
|
|
186
|
+
"""
|
|
187
|
+
return None
|
|
188
|
+
|
|
189
|
+
async def aclose(self) -> None:
|
|
190
|
+
"""Release runtime resources (containers, subprocesses, MCP
|
|
191
|
+
servers). Default no-op.
|
|
192
|
+
"""
|
|
193
|
+
return None
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
# Case-insensitive substrings that mark a claude CLI output as an
|
|
197
|
+
# auth failure rather than a real reply. Kept deliberately strong so
|
|
198
|
+
# a user asking about HTTP auth doesn't flip the health flag.
|
|
199
|
+
_AUTH_FAILURE_SIGNATURES = (
|
|
200
|
+
"api error: 401",
|
|
201
|
+
"invalid authentication credentials",
|
|
202
|
+
'"type":"authentication_error"',
|
|
203
|
+
"authentication_error",
|
|
204
|
+
"invalid_grant",
|
|
205
|
+
"please run /login",
|
|
206
|
+
"please run `claude /login`",
|
|
207
|
+
"run `claude login`",
|
|
208
|
+
)
|
|
209
|
+
|
|
210
|
+
|
|
211
|
+
def looks_like_auth_failure(*parts: str) -> bool:
|
|
212
|
+
"""True if any string contains a claude auth-failure signature.
|
|
213
|
+
Case-insensitive.
|
|
214
|
+
"""
|
|
215
|
+
for p in parts:
|
|
216
|
+
if not p:
|
|
217
|
+
continue
|
|
218
|
+
low = p.lower()
|
|
219
|
+
if any(sig in low for sig in _AUTH_FAILURE_SIGNATURES):
|
|
220
|
+
return True
|
|
221
|
+
return False
|
|
222
|
+
|
|
223
|
+
|
|
224
|
+
def format_history_as_prompt(messages: list[dict]) -> str:
|
|
225
|
+
"""Render shell conversation history as a single prompt string.
|
|
226
|
+
Used by the SDK adapter (one-shot per turn); CLI adapters keep a
|
|
227
|
+
long-lived session that owns its own transcript.
|
|
228
|
+
"""
|
|
229
|
+
if not messages:
|
|
230
|
+
return ""
|
|
231
|
+
if len(messages) == 1:
|
|
232
|
+
return messages[0]["content"]
|
|
233
|
+
parts = ["<prior_turns>"]
|
|
234
|
+
for m in messages[:-1]:
|
|
235
|
+
parts.append(f"[{m['role']}]\n{m['content']}")
|
|
236
|
+
parts.append("</prior_turns>")
|
|
237
|
+
parts.append(messages[-1]["content"])
|
|
238
|
+
return "\n\n".join(parts)
|
|
@@ -0,0 +1,30 @@
|
|
|
1
|
+
"""Chat-only adapter.
|
|
2
|
+
|
|
3
|
+
Wraps the message-completion providers (Anthropic/OpenAI) so existing
|
|
4
|
+
agents keep working without the SDK or CLI runtimes. Does not run
|
|
5
|
+
tools, does not touch the filesystem, ignores workspace/claude dirs.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from __future__ import annotations
|
|
9
|
+
|
|
10
|
+
import asyncio
|
|
11
|
+
|
|
12
|
+
from .base import Adapter, TurnContext, TurnResult
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ChatOnlyAdapter(Adapter):
|
|
16
|
+
def __init__(self, provider):
|
|
17
|
+
# ``provider`` exposes blocking
|
|
18
|
+
# ``complete(system_prompt, messages) -> (str, int, int)``.
|
|
19
|
+
self._provider = provider
|
|
20
|
+
|
|
21
|
+
async def run_turn(self, ctx: TurnContext) -> TurnResult:
|
|
22
|
+
reply, input_tokens, output_tokens = await asyncio.to_thread(
|
|
23
|
+
self._provider.complete, ctx.system_prompt, ctx.messages,
|
|
24
|
+
)
|
|
25
|
+
return TurnResult(
|
|
26
|
+
reply=reply,
|
|
27
|
+
input_tokens=input_tokens,
|
|
28
|
+
output_tokens=output_tokens,
|
|
29
|
+
tool_calls=0,
|
|
30
|
+
)
|