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.
Files changed (71) hide show
  1. puffo_agent/__init__.py +0 -0
  2. puffo_agent/agent/__init__.py +0 -0
  3. puffo_agent/agent/_logging.py +18 -0
  4. puffo_agent/agent/_time.py +18 -0
  5. puffo_agent/agent/adapters/__init__.py +12 -0
  6. puffo_agent/agent/adapters/base.py +238 -0
  7. puffo_agent/agent/adapters/chat_only.py +30 -0
  8. puffo_agent/agent/adapters/cli_session.py +636 -0
  9. puffo_agent/agent/adapters/docker_cli.py +1220 -0
  10. puffo_agent/agent/adapters/local_cli.py +526 -0
  11. puffo_agent/agent/adapters/sdk.py +195 -0
  12. puffo_agent/agent/core.py +247 -0
  13. puffo_agent/agent/events.py +52 -0
  14. puffo_agent/agent/file_browser.py +106 -0
  15. puffo_agent/agent/harness/__init__.py +40 -0
  16. puffo_agent/agent/harness/base.py +53 -0
  17. puffo_agent/agent/harness/claude_code.py +22 -0
  18. puffo_agent/agent/harness/gemini_cli.py +18 -0
  19. puffo_agent/agent/harness/hermes.py +32 -0
  20. puffo_agent/agent/memory.py +37 -0
  21. puffo_agent/agent/message_store.py +254 -0
  22. puffo_agent/agent/providers/__init__.py +0 -0
  23. puffo_agent/agent/providers/anthropic_provider.py +19 -0
  24. puffo_agent/agent/providers/openai_provider.py +20 -0
  25. puffo_agent/agent/puffo_core_client.py +1333 -0
  26. puffo_agent/agent/shared_content.py +772 -0
  27. puffo_agent/agent/skills/__init__.py +0 -0
  28. puffo_agent/agent/skills_loader.py +41 -0
  29. puffo_agent/agent/status_reporter.py +128 -0
  30. puffo_agent/crypto/__init__.py +0 -0
  31. puffo_agent/crypto/attachments.py +100 -0
  32. puffo_agent/crypto/canonical.py +83 -0
  33. puffo_agent/crypto/certs.py +71 -0
  34. puffo_agent/crypto/encoding.py +17 -0
  35. puffo_agent/crypto/fingerprint.py +25 -0
  36. puffo_agent/crypto/http_auth.py +118 -0
  37. puffo_agent/crypto/http_client.py +194 -0
  38. puffo_agent/crypto/keystore.py +156 -0
  39. puffo_agent/crypto/message.py +272 -0
  40. puffo_agent/crypto/primitives.py +135 -0
  41. puffo_agent/crypto/v2_aad.py +98 -0
  42. puffo_agent/crypto/ws_client.py +193 -0
  43. puffo_agent/hooks/__init__.py +0 -0
  44. puffo_agent/hooks/permission.py +268 -0
  45. puffo_agent/mcp/__init__.py +5 -0
  46. puffo_agent/mcp/config.py +159 -0
  47. puffo_agent/mcp/data_client.py +160 -0
  48. puffo_agent/mcp/host_tools.py +254 -0
  49. puffo_agent/mcp/puffo_core_server.py +230 -0
  50. puffo_agent/mcp/puffo_core_tools.py +533 -0
  51. puffo_agent/portal/__init__.py +0 -0
  52. puffo_agent/portal/api/__init__.py +11 -0
  53. puffo_agent/portal/api/auth.py +138 -0
  54. puffo_agent/portal/api/certs.py +151 -0
  55. puffo_agent/portal/api/cors.py +84 -0
  56. puffo_agent/portal/api/handlers.py +1196 -0
  57. puffo_agent/portal/api/ownership.py +53 -0
  58. puffo_agent/portal/api/pairing.py +80 -0
  59. puffo_agent/portal/api/server.py +95 -0
  60. puffo_agent/portal/cli.py +1194 -0
  61. puffo_agent/portal/daemon.py +351 -0
  62. puffo_agent/portal/data_service.py +229 -0
  63. puffo_agent/portal/runtime_matrix.py +225 -0
  64. puffo_agent/portal/state.py +1005 -0
  65. puffo_agent/portal/worker.py +799 -0
  66. puffo_agent-0.7.2.dist-info/METADATA +346 -0
  67. puffo_agent-0.7.2.dist-info/RECORD +71 -0
  68. puffo_agent-0.7.2.dist-info/WHEEL +5 -0
  69. puffo_agent-0.7.2.dist-info/entry_points.txt +2 -0
  70. puffo_agent-0.7.2.dist-info/licenses/LICENSE +21 -0
  71. puffo_agent-0.7.2.dist-info/top_level.txt +1 -0
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
+ )