labs-agent-lib 0.1.0__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.
- labs_agent_lib/__init__.py +16 -0
- labs_agent_lib/agent_environment/__init__.py +12 -0
- labs_agent_lib/agent_environment/agent_environment.py +55 -0
- labs_agent_lib/agent_settings.py +62 -0
- labs_agent_lib/file_reader/__init__.py +8 -0
- labs_agent_lib/file_reader/docx_reader.py +9 -0
- labs_agent_lib/file_reader/errors.py +13 -0
- labs_agent_lib/file_reader/extractor.py +35 -0
- labs_agent_lib/file_reader/pdf_reader.py +11 -0
- labs_agent_lib/file_reader/text_reader.py +7 -0
- labs_agent_lib/file_writer/__init__.py +7 -0
- labs_agent_lib/file_writer/resume_mover.py +13 -0
- labs_agent_lib/logger.py +37 -0
- labs_agent_lib/observability/__init__.py +11 -0
- labs_agent_lib/observability/_jsonl_writer.py +31 -0
- labs_agent_lib/observability/agent_transcript.py +159 -0
- labs_agent_lib/observability/prompt_log.py +55 -0
- labs_agent_lib-0.1.0.dist-info/METADATA +81 -0
- labs_agent_lib-0.1.0.dist-info/RECORD +21 -0
- labs_agent_lib-0.1.0.dist-info/WHEEL +4 -0
- labs_agent_lib-0.1.0.dist-info/licenses/LICENSE +21 -0
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
from .logger import get_logger
|
|
2
|
+
from .agent_settings import get_agent_settings
|
|
3
|
+
from .file_reader.extractor import extract_text
|
|
4
|
+
from .file_writer import file_writer
|
|
5
|
+
from .agent_environment import get_agent_environment
|
|
6
|
+
from .observability import AgentTranscriptWriter, PromptLogWriter
|
|
7
|
+
|
|
8
|
+
__all__ = [
|
|
9
|
+
"get_logger",
|
|
10
|
+
"get_agent_settings",
|
|
11
|
+
"extract_text",
|
|
12
|
+
"file_writer",
|
|
13
|
+
"get_agent_environment",
|
|
14
|
+
"AgentTranscriptWriter",
|
|
15
|
+
"PromptLogWriter",
|
|
16
|
+
]
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
from labs_agent_lib.agent_settings import AgentSettings, LogLevel, Provider, get_agent_settings
|
|
2
|
+
|
|
3
|
+
from .agent_environment import AgentEnvironment, get_agent_environment
|
|
4
|
+
|
|
5
|
+
__all__ = [
|
|
6
|
+
"AgentEnvironment",
|
|
7
|
+
"AgentSettings",
|
|
8
|
+
"LogLevel",
|
|
9
|
+
"Provider",
|
|
10
|
+
"get_agent_environment",
|
|
11
|
+
"get_agent_settings",
|
|
12
|
+
]
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Builds the Claude Agent SDK environment for the gateway configured in AgentSettings."""
|
|
2
|
+
|
|
3
|
+
from dataclasses import dataclass
|
|
4
|
+
|
|
5
|
+
from labs_agent_lib.agent_settings import AgentSettings, Provider
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
@dataclass(frozen=True, slots=True)
|
|
9
|
+
class AgentEnvironment:
|
|
10
|
+
"""What ClaudeAgentOptions needs to reach a model gateway.
|
|
11
|
+
|
|
12
|
+
Attributes:
|
|
13
|
+
env: Environment overrides for ClaudeAgentOptions.env; the SDK merges this
|
|
14
|
+
over the inherited environment before spawning the `claude` subprocess.
|
|
15
|
+
The model itself is set in code, not here.
|
|
16
|
+
"""
|
|
17
|
+
|
|
18
|
+
env: dict[str, str]
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def get_agent_environment(settings: AgentSettings) -> AgentEnvironment:
|
|
22
|
+
"""Build the AgentEnvironment for the provider configured in settings.provider.
|
|
23
|
+
|
|
24
|
+
Both supported providers speak the Anthropic Messages API, so routing to either
|
|
25
|
+
one is just a matter of pointing ANTHROPIC_BASE_URL/ANTHROPIC_API_KEY at it.
|
|
26
|
+
"""
|
|
27
|
+
if settings.provider is Provider.PORTKEY:
|
|
28
|
+
base_url, api_key = settings.portkey_base_url, settings.portkey_api_key
|
|
29
|
+
else:
|
|
30
|
+
base_url, api_key = settings.anthropic_base_url, settings.anthropic_api_key
|
|
31
|
+
|
|
32
|
+
if not api_key.get_secret_value():
|
|
33
|
+
raise ValueError(
|
|
34
|
+
f"LABS_AGENT_LIB_{settings.provider.value.upper()}_API_KEY is required when provider={settings.provider}"
|
|
35
|
+
)
|
|
36
|
+
|
|
37
|
+
if settings.provider is Provider.PORTKEY:
|
|
38
|
+
headers = f"x-portkey-api-key: {api_key.get_secret_value()}"
|
|
39
|
+
if settings.portkey_virtual:
|
|
40
|
+
headers += f"\nx-portkey-virtual-key: {settings.portkey_virtual}"
|
|
41
|
+
env = {
|
|
42
|
+
"ANTHROPIC_BASE_URL": base_url,
|
|
43
|
+
"ANTHROPIC_API_KEY": api_key.get_secret_value(),
|
|
44
|
+
"ANTHROPIC_CUSTOM_HEADERS": headers,
|
|
45
|
+
}
|
|
46
|
+
else:
|
|
47
|
+
env = {
|
|
48
|
+
"ANTHROPIC_BASE_URL": base_url,
|
|
49
|
+
"ANTHROPIC_API_KEY": api_key.get_secret_value(),
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
if settings.disable_prompt_caching:
|
|
53
|
+
env["DISABLE_PROMPT_CACHING"] = "1"
|
|
54
|
+
|
|
55
|
+
return AgentEnvironment(env=env)
|
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""Application settings for projects built on labs-agent-lib.
|
|
2
|
+
|
|
3
|
+
Consuming projects configure this via a `.env` file (or real env vars) at their own
|
|
4
|
+
repository root, prefixing every key with `LABS_AGENT_LIB_`, e.g.:
|
|
5
|
+
|
|
6
|
+
LABS_AGENT_LIB_PROVIDER=anthropic
|
|
7
|
+
LABS_AGENT_LIB_LOG_LEVEL=DEBUG
|
|
8
|
+
LABS_AGENT_LIB_ANTHROPIC_API_KEY=sk-ant-...
|
|
9
|
+
|
|
10
|
+
Call `get_agent_settings()` to get the cached singleton instead of instantiating
|
|
11
|
+
`AgentSettings` directly, so the `.env` file is only read once per process.
|
|
12
|
+
"""
|
|
13
|
+
|
|
14
|
+
from enum import StrEnum
|
|
15
|
+
from functools import lru_cache
|
|
16
|
+
|
|
17
|
+
from pydantic import SecretStr
|
|
18
|
+
from pydantic_settings import BaseSettings, SettingsConfigDict
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Provider(StrEnum):
|
|
22
|
+
"""Anthropic-Messages-API-compatible gateways this agent can route through."""
|
|
23
|
+
|
|
24
|
+
PORTKEY = "portkey"
|
|
25
|
+
ANTHROPIC = "anthropic"
|
|
26
|
+
|
|
27
|
+
|
|
28
|
+
class LogLevel(StrEnum):
|
|
29
|
+
"""Verbosity for labs_resume_agent.libs.logger - maps 1:1 to stdlib logging levels."""
|
|
30
|
+
|
|
31
|
+
DEBUG = "DEBUG"
|
|
32
|
+
INFO = "INFO"
|
|
33
|
+
WARNING = "WARNING"
|
|
34
|
+
ERROR = "ERROR"
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class AgentSettings(BaseSettings):
|
|
38
|
+
model_config = SettingsConfigDict(
|
|
39
|
+
env_file=".env",
|
|
40
|
+
env_prefix="LABS_AGENT_LIB_",
|
|
41
|
+
env_file_encoding="utf-8",
|
|
42
|
+
extra="ignore",
|
|
43
|
+
populate_by_name=True,
|
|
44
|
+
)
|
|
45
|
+
|
|
46
|
+
provider: Provider = Provider.PORTKEY
|
|
47
|
+
log_level: LogLevel = LogLevel.INFO
|
|
48
|
+
|
|
49
|
+
portkey_base_url: str = "https://portkeygateway.perficient.com"
|
|
50
|
+
portkey_api_key: SecretStr = SecretStr("")
|
|
51
|
+
portkey_virtual: str = ""
|
|
52
|
+
|
|
53
|
+
anthropic_base_url: str = "https://api.anthropic.com"
|
|
54
|
+
anthropic_api_key: SecretStr = SecretStr("")
|
|
55
|
+
|
|
56
|
+
disable_prompt_caching: bool = False
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
@lru_cache
|
|
60
|
+
def get_agent_settings() -> AgentSettings:
|
|
61
|
+
"""Return the cached application settings singleton."""
|
|
62
|
+
return AgentSettings()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Errors raised by the file_reader library."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
class UnsupportedFileTypeError(ValueError):
|
|
5
|
+
"""Raised when a file's extension has no registered reader."""
|
|
6
|
+
|
|
7
|
+
def __init__(self, extension: str, supported: list[str]) -> None:
|
|
8
|
+
self.extension = extension
|
|
9
|
+
self.supported = supported
|
|
10
|
+
super().__init__(
|
|
11
|
+
f"Unsupported file type '{extension or '(none)'}'. "
|
|
12
|
+
f"Supported extensions: {', '.join(supported)}"
|
|
13
|
+
)
|
|
@@ -0,0 +1,35 @@
|
|
|
1
|
+
"""Picks a reader for a document from its extension and extracts its text."""
|
|
2
|
+
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from .docx_reader import read_docx
|
|
6
|
+
from .errors import UnsupportedFileTypeError
|
|
7
|
+
from .pdf_reader import read_pdf
|
|
8
|
+
from .text_reader import read_text_file
|
|
9
|
+
|
|
10
|
+
_READERS = {
|
|
11
|
+
".pdf": read_pdf,
|
|
12
|
+
".docx": read_docx,
|
|
13
|
+
".txt": read_text_file,
|
|
14
|
+
".md": read_text_file,
|
|
15
|
+
".markdown": read_text_file,
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
SUPPORTED_EXTENSIONS = sorted(_READERS)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
def extract_text(file_path: str) -> str:
|
|
22
|
+
"""Read `file_path` and return its text content.
|
|
23
|
+
|
|
24
|
+
The reader is picked from the file's extension (see SUPPORTED_EXTENSIONS);
|
|
25
|
+
callers don't need to know which parser handles which format.
|
|
26
|
+
"""
|
|
27
|
+
path = Path(file_path)
|
|
28
|
+
if not path.is_file():
|
|
29
|
+
raise FileNotFoundError(f"File not found: {path}")
|
|
30
|
+
|
|
31
|
+
read = _READERS.get(path.suffix.lower())
|
|
32
|
+
if read is None:
|
|
33
|
+
raise UnsupportedFileTypeError(path.suffix, SUPPORTED_EXTENSIONS)
|
|
34
|
+
|
|
35
|
+
return read(path)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Extracts text from PDF files."""
|
|
2
|
+
|
|
3
|
+
|
|
4
|
+
import pymupdf
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
|
|
7
|
+
def read_pdf(path: Path) -> str:
|
|
8
|
+
"""Return the concatenated text of every page in a PDF file."""
|
|
9
|
+
doc = pymupdf.open(path)
|
|
10
|
+
pages = [page.get_text("text") for page in doc]
|
|
11
|
+
return "\n\n".join(pages).strip()
|
|
@@ -0,0 +1,13 @@
|
|
|
1
|
+
"""Moves resume files into their target folder."""
|
|
2
|
+
|
|
3
|
+
import shutil
|
|
4
|
+
from pathlib import Path
|
|
5
|
+
|
|
6
|
+
|
|
7
|
+
def file_writer(source_file_path: str, target_path: str) -> None:
|
|
8
|
+
"""Moves `source_file_path` into `target_path`, creating the folder if needed."""
|
|
9
|
+
source = Path(source_file_path)
|
|
10
|
+
target_dir = Path(target_path)
|
|
11
|
+
target_dir.mkdir(parents=True, exist_ok=True)
|
|
12
|
+
|
|
13
|
+
shutil.move(source_file_path, target_dir / source.name)
|
labs_agent_lib/logger.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
"""Centralized logging setup for the whole app (coordinators, subagent task events, MCP tools).
|
|
2
|
+
|
|
3
|
+
Usage: `logger = get_logger(__name__)` at module level, then `logger.debug/info/warning/error(...)`.
|
|
4
|
+
Verbosity is controlled by AgentSettings.log_level (LABS_RESUME_AGENT_LOG_LEVEL) - set it to DEBUG to
|
|
5
|
+
see every step (tool calls, per-task progress) or leave it at INFO to only see the high-level
|
|
6
|
+
phases (task started/done, final results). Logs go to stderr, not stdout: mcp_server.py runs as
|
|
7
|
+
an MCP stdio subprocess, and anything printed to stdout there would corrupt the MCP protocol.
|
|
8
|
+
"""
|
|
9
|
+
|
|
10
|
+
import logging
|
|
11
|
+
import sys
|
|
12
|
+
|
|
13
|
+
from labs_agent_lib.agent_settings import get_agent_settings
|
|
14
|
+
|
|
15
|
+
_configured_roots: set[str] = set()
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def _configure(root_name: str) -> None:
|
|
19
|
+
if root_name in _configured_roots:
|
|
20
|
+
return
|
|
21
|
+
root = logging.getLogger(root_name)
|
|
22
|
+
root.setLevel(get_agent_settings().log_level.value)
|
|
23
|
+
handler = logging.StreamHandler(sys.stderr)
|
|
24
|
+
handler.setFormatter(logging.Formatter("%(asctime)s %(levelname)-8s %(name)s: %(message)s"))
|
|
25
|
+
root.addHandler(handler)
|
|
26
|
+
root.propagate = False
|
|
27
|
+
_configured_roots.add(root_name)
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
def get_logger(name: str) -> logging.Logger:
|
|
31
|
+
"""Return a logger in the caller's package hierarchy; pass __name__ from the call site.
|
|
32
|
+
|
|
33
|
+
The top-level package segment of `name` is configured as its own logging root on first
|
|
34
|
+
use, so each consuming project gets its level/handler without hardcoding its package name.
|
|
35
|
+
"""
|
|
36
|
+
_configure(name.split(".", 1)[0])
|
|
37
|
+
return logging.getLogger(name)
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
"""Shared prompt-log / agent-transcript JSONL writers for Claude-agent-based projects.
|
|
2
|
+
|
|
3
|
+
Each consuming project supplies its own settings object (just needs
|
|
4
|
+
`observability_enabled`, `prompt_log_dir`, `agent_transcript_dir`) and a `service` name;
|
|
5
|
+
see `PromptLogWriter`/`AgentTranscriptWriter` for the full contract.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from .agent_transcript import AgentTranscriptWriter
|
|
9
|
+
from .prompt_log import PromptLogWriter
|
|
10
|
+
|
|
11
|
+
__all__ = ["AgentTranscriptWriter", "PromptLogWriter"]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Shared JSONL append helper backing the prompt log and agent transcript writers."""
|
|
2
|
+
|
|
3
|
+
from datetime import UTC, datetime
|
|
4
|
+
import json
|
|
5
|
+
from pathlib import Path
|
|
6
|
+
from typing import Any
|
|
7
|
+
|
|
8
|
+
|
|
9
|
+
def append_jsonl(directory: str, file_stem: str, record: dict[str, Any]) -> None:
|
|
10
|
+
"""Appends one JSON line to `{directory}/{file_stem}.jsonl`, creating the directory if needed."""
|
|
11
|
+
path = Path(directory)
|
|
12
|
+
path.mkdir(parents=True, exist_ok=True)
|
|
13
|
+
line = json.dumps(record, ensure_ascii=False, default=str)
|
|
14
|
+
with (path / f"{file_stem}.jsonl").open("a", encoding="utf-8") as handle:
|
|
15
|
+
handle.write(line + "\n")
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
def utc_now_iso() -> str:
|
|
19
|
+
"""Returns the current UTC time as an ISO-8601 string."""
|
|
20
|
+
return datetime.now(UTC).isoformat()
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
def file_stem_for(correlation_id: str, agent: str | None = None) -> str:
|
|
24
|
+
"""Builds the JSONL file stem for one `correlation_id`, optionally keyed by agent too.
|
|
25
|
+
|
|
26
|
+
Pass `agent` when several agents can write concurrently under the same
|
|
27
|
+
`correlation_id` (e.g. parallel sub-agent tool calls fanned out by a coordinator in
|
|
28
|
+
one turn), so each gets its own file instead of interleaving writes into a shared one.
|
|
29
|
+
"""
|
|
30
|
+
base = correlation_id or "no-correlation-id"
|
|
31
|
+
return f"{base}__{agent}" if agent else base
|
|
@@ -0,0 +1,159 @@
|
|
|
1
|
+
"""Agent transcript: the full, ordered play-by-play of one agent session or tool call.
|
|
2
|
+
|
|
3
|
+
Complements the prompt log (a flat index of what was asked). See a consuming project's
|
|
4
|
+
`documentation/prompt_log_and_agent_transcript.md` for the full design and how
|
|
5
|
+
`correlation_id` ties multiple services together.
|
|
6
|
+
"""
|
|
7
|
+
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
from ._jsonl_writer import append_jsonl, file_stem_for, utc_now_iso
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
class AgentTranscriptWriter:
|
|
14
|
+
"""Appends one event line per step of an agent run to `{agent_transcript_dir}/{file_stem}.jsonl`.
|
|
15
|
+
|
|
16
|
+
`settings` only needs `observability_enabled: bool` and `agent_transcript_dir: str`
|
|
17
|
+
attributes - any project's own settings object works, no shared schema required.
|
|
18
|
+
`service` names the calling project in every record (e.g. `"mcp_skills_extractor_agents"`).
|
|
19
|
+
Pass `key_by_agent=True` when multiple agents can write under the same
|
|
20
|
+
`correlation_id` concurrently, so each gets its own file instead of interleaving
|
|
21
|
+
writes - e.g. several extraction sub-agents fanned out in parallel by a coordinator.
|
|
22
|
+
|
|
23
|
+
Methods cover the union of events either a coordinator (`session_start`,
|
|
24
|
+
`assistant_text`, `tool_use`, `tool_result`, `session_end`) or a self-correcting
|
|
25
|
+
sub-agent (`session_start`, `assistant_text`, `retry`, `session_end`) needs. Callers
|
|
26
|
+
only use the subset that applies to them.
|
|
27
|
+
"""
|
|
28
|
+
|
|
29
|
+
def __init__(self, settings: Any, *, service: str, key_by_agent: bool = False) -> None:
|
|
30
|
+
self.settings = settings
|
|
31
|
+
self.service = service
|
|
32
|
+
self.key_by_agent = key_by_agent
|
|
33
|
+
|
|
34
|
+
def _write(
|
|
35
|
+
self,
|
|
36
|
+
correlation_id: str,
|
|
37
|
+
agent: str,
|
|
38
|
+
event: str,
|
|
39
|
+
*,
|
|
40
|
+
session_id: str | None = None,
|
|
41
|
+
**fields: Any,
|
|
42
|
+
) -> None:
|
|
43
|
+
if not self.settings.observability_enabled:
|
|
44
|
+
return
|
|
45
|
+
|
|
46
|
+
record: dict[str, Any] = {
|
|
47
|
+
"timestamp": utc_now_iso(),
|
|
48
|
+
"correlation_id": correlation_id,
|
|
49
|
+
"service": self.service,
|
|
50
|
+
"agent": agent,
|
|
51
|
+
"session_id": session_id,
|
|
52
|
+
"event": event,
|
|
53
|
+
**fields,
|
|
54
|
+
}
|
|
55
|
+
file_stem = file_stem_for(correlation_id, agent if self.key_by_agent else None)
|
|
56
|
+
append_jsonl(self.settings.agent_transcript_dir, file_stem, record)
|
|
57
|
+
|
|
58
|
+
def session_start(
|
|
59
|
+
self,
|
|
60
|
+
correlation_id: str,
|
|
61
|
+
agent: str,
|
|
62
|
+
*,
|
|
63
|
+
model: str,
|
|
64
|
+
system_prompt: str,
|
|
65
|
+
user_prompt: str,
|
|
66
|
+
allowed_tools: list[str] | None = None,
|
|
67
|
+
) -> None:
|
|
68
|
+
"""Records the opening of a session/first attempt, including the system prompt once."""
|
|
69
|
+
self._write(
|
|
70
|
+
correlation_id,
|
|
71
|
+
agent,
|
|
72
|
+
"session_start",
|
|
73
|
+
model=model,
|
|
74
|
+
system_prompt=system_prompt,
|
|
75
|
+
user_prompt=user_prompt,
|
|
76
|
+
allowed_tools=list(allowed_tools) if allowed_tools is not None else None,
|
|
77
|
+
)
|
|
78
|
+
|
|
79
|
+
def assistant_text(
|
|
80
|
+
self,
|
|
81
|
+
correlation_id: str,
|
|
82
|
+
agent: str,
|
|
83
|
+
*,
|
|
84
|
+
text: str,
|
|
85
|
+
session_id: str | None = None,
|
|
86
|
+
) -> None:
|
|
87
|
+
"""Records a text block/raw response the agent emitted."""
|
|
88
|
+
self._write(correlation_id, agent, "assistant_text", text=text, session_id=session_id)
|
|
89
|
+
|
|
90
|
+
def tool_use(
|
|
91
|
+
self,
|
|
92
|
+
correlation_id: str,
|
|
93
|
+
agent: str,
|
|
94
|
+
*,
|
|
95
|
+
tool_use_id: str,
|
|
96
|
+
tool_name: str,
|
|
97
|
+
target_field: str,
|
|
98
|
+
tool_input: Any,
|
|
99
|
+
) -> None:
|
|
100
|
+
"""Records a tool call the agent dispatched."""
|
|
101
|
+
self._write(
|
|
102
|
+
correlation_id,
|
|
103
|
+
agent,
|
|
104
|
+
"tool_use",
|
|
105
|
+
tool_use_id=tool_use_id,
|
|
106
|
+
tool_name=tool_name,
|
|
107
|
+
target_field=target_field,
|
|
108
|
+
tool_input=tool_input,
|
|
109
|
+
)
|
|
110
|
+
|
|
111
|
+
def tool_result(
|
|
112
|
+
self,
|
|
113
|
+
correlation_id: str,
|
|
114
|
+
agent: str,
|
|
115
|
+
*,
|
|
116
|
+
tool_use_id: str,
|
|
117
|
+
tool_name: str,
|
|
118
|
+
target_field: str,
|
|
119
|
+
is_error: bool,
|
|
120
|
+
output: str,
|
|
121
|
+
) -> None:
|
|
122
|
+
"""Records the result that came back for a previously dispatched tool call."""
|
|
123
|
+
self._write(
|
|
124
|
+
correlation_id,
|
|
125
|
+
agent,
|
|
126
|
+
"tool_result",
|
|
127
|
+
tool_use_id=tool_use_id,
|
|
128
|
+
tool_name=tool_name,
|
|
129
|
+
target_field=target_field,
|
|
130
|
+
is_error=is_error,
|
|
131
|
+
output=output,
|
|
132
|
+
)
|
|
133
|
+
|
|
134
|
+
def retry(self, correlation_id: str, agent: str, *, attempt: int, attempts: int, error: str) -> None:
|
|
135
|
+
"""Records a self-correction turn triggered by a schema/validation failure."""
|
|
136
|
+
self._write(correlation_id, agent, "retry", attempt=attempt, attempts=attempts, error=error)
|
|
137
|
+
|
|
138
|
+
def session_end(
|
|
139
|
+
self,
|
|
140
|
+
correlation_id: str,
|
|
141
|
+
agent: str,
|
|
142
|
+
*,
|
|
143
|
+
is_error: bool,
|
|
144
|
+
session_id: str | None = None,
|
|
145
|
+
stop_reason: str | None = None,
|
|
146
|
+
result: Any = None,
|
|
147
|
+
error: str | None = None,
|
|
148
|
+
) -> None:
|
|
149
|
+
"""Records the final outcome of the session/tool call."""
|
|
150
|
+
self._write(
|
|
151
|
+
correlation_id,
|
|
152
|
+
agent,
|
|
153
|
+
"session_end",
|
|
154
|
+
session_id=session_id,
|
|
155
|
+
stop_reason=stop_reason,
|
|
156
|
+
is_error=is_error,
|
|
157
|
+
result=result,
|
|
158
|
+
error=error,
|
|
159
|
+
)
|
|
@@ -0,0 +1,55 @@
|
|
|
1
|
+
"""Prompt log: one audit line per outbound LLM request attempt - "what we asked the model".
|
|
2
|
+
|
|
3
|
+
Complements the agent transcript (the full play-by-play of a session). The prompt log is
|
|
4
|
+
a flat, append-only index meant for quick search/filtering across runs; see a consuming
|
|
5
|
+
project's `documentation/prompt_log_and_agent_transcript.md` for the full design and how
|
|
6
|
+
`correlation_id` ties multiple services together.
|
|
7
|
+
"""
|
|
8
|
+
|
|
9
|
+
from typing import Any
|
|
10
|
+
|
|
11
|
+
from ._jsonl_writer import append_jsonl, file_stem_for, utc_now_iso
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class PromptLogWriter:
|
|
15
|
+
"""Appends one line per LLM call attempt to `{prompt_log_dir}/{file_stem}.jsonl`.
|
|
16
|
+
|
|
17
|
+
`settings` only needs `observability_enabled: bool` and `prompt_log_dir: str`
|
|
18
|
+
attributes - any project's own settings object works, no shared schema required.
|
|
19
|
+
`service` names the calling project in every record (e.g. `"talent_distiller_agent"`).
|
|
20
|
+
Pass `key_by_agent=True` when multiple agents can log under the same `correlation_id`
|
|
21
|
+
concurrently, so each gets its own file instead of interleaving writes.
|
|
22
|
+
"""
|
|
23
|
+
|
|
24
|
+
def __init__(self, settings: Any, *, service: str, key_by_agent: bool = False) -> None:
|
|
25
|
+
self.settings = settings
|
|
26
|
+
self.service = service
|
|
27
|
+
self.key_by_agent = key_by_agent
|
|
28
|
+
|
|
29
|
+
def log_prompt(
|
|
30
|
+
self,
|
|
31
|
+
*,
|
|
32
|
+
correlation_id: str,
|
|
33
|
+
agent: str,
|
|
34
|
+
model: str,
|
|
35
|
+
system_prompt_name: str,
|
|
36
|
+
user_prompt: str,
|
|
37
|
+
attempt: int = 1,
|
|
38
|
+
) -> None:
|
|
39
|
+
"""Records one outbound prompt. No-ops when `settings.observability_enabled` is `False`."""
|
|
40
|
+
if not self.settings.observability_enabled:
|
|
41
|
+
return
|
|
42
|
+
|
|
43
|
+
record: dict[str, Any] = {
|
|
44
|
+
"timestamp": utc_now_iso(),
|
|
45
|
+
"correlation_id": correlation_id,
|
|
46
|
+
"service": self.service,
|
|
47
|
+
"agent": agent,
|
|
48
|
+
"event": "prompt",
|
|
49
|
+
"model": model,
|
|
50
|
+
"system_prompt_name": system_prompt_name,
|
|
51
|
+
"user_prompt": user_prompt,
|
|
52
|
+
"attempt": attempt,
|
|
53
|
+
}
|
|
54
|
+
file_stem = file_stem_for(correlation_id, agent if self.key_by_agent else None)
|
|
55
|
+
append_jsonl(self.settings.prompt_log_dir, file_stem, record)
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
Metadata-Version: 2.4
|
|
2
|
+
Name: labs-agent-lib
|
|
3
|
+
Version: 0.1.0
|
|
4
|
+
Summary: Shared utilities (settings, logging, document text extraction, Claude Agent SDK environment) for Claude-agent-based projects.
|
|
5
|
+
Author: Luis Alberto Bolaños Salazar
|
|
6
|
+
Author-email: Luis Alberto Bolaños Salazar <68343185+albertbolanoss@users.noreply.github.com>
|
|
7
|
+
License-Expression: MIT
|
|
8
|
+
License-File: LICENSE
|
|
9
|
+
Requires-Dist: docx2txt>=0.9
|
|
10
|
+
Requires-Dist: pydantic>=2.14.1
|
|
11
|
+
Requires-Dist: pydantic-settings>=2.16.1
|
|
12
|
+
Requires-Dist: pymupdf>=2.0
|
|
13
|
+
Requires-Dist: python-dotenv>=1.2.4
|
|
14
|
+
Requires-Python: >=3.14
|
|
15
|
+
Description-Content-Type: text/markdown
|
|
16
|
+
|
|
17
|
+
# labs-agent-lib
|
|
18
|
+
|
|
19
|
+
Shared utilities extracted from [`candidate-match-agent`](https://github.com/albertbolanoss/candidate-match-agent) for
|
|
20
|
+
building Claude-agent-based projects:
|
|
21
|
+
|
|
22
|
+
- `labs_agent_lib.agent_settings` — a `pydantic-settings` `AgentSettings` model (gateway provider, log level, Portkey/Anthropic
|
|
23
|
+
credentials, prompt-caching toggle).
|
|
24
|
+
- `labs_agent_lib.logger` — centralized stderr logging, configured from `AgentSettings.log_level`.
|
|
25
|
+
- `labs_agent_lib.file_reader` — extracts plain text from `.pdf`, `.docx`, `.txt`, `.md`, and `.markdown` files.
|
|
26
|
+
- `labs_agent_lib.agent_environment` — builds the `ClaudeAgentOptions.env` dict (base URL, API key, custom headers)
|
|
27
|
+
for the configured provider.
|
|
28
|
+
- `labs_agent_lib.observability` — `PromptLogWriter`/`AgentTranscriptWriter`: JSONL writers for a prompt log
|
|
29
|
+
("what we asked the model") and an agent transcript ("what happened, step by step"), correlated by a
|
|
30
|
+
caller-supplied `correlation_id`. Each consuming project supplies its own settings object (just needs
|
|
31
|
+
`observability_enabled`, `prompt_log_dir`, `agent_transcript_dir`) and a `service` name.
|
|
32
|
+
|
|
33
|
+
## Install
|
|
34
|
+
|
|
35
|
+
```bash
|
|
36
|
+
uv add labs-agent-lib
|
|
37
|
+
# or
|
|
38
|
+
pip install labs-agent-lib
|
|
39
|
+
```
|
|
40
|
+
|
|
41
|
+
## Configuration
|
|
42
|
+
|
|
43
|
+
`AgentSettings` reads from your project's own `.env` file (or real environment variables), with every key
|
|
44
|
+
prefixed `LABS_AGENT_LIB_`:
|
|
45
|
+
|
|
46
|
+
| Env var | Default | Description |
|
|
47
|
+
| --- | --- | --- |
|
|
48
|
+
| `LABS_AGENT_LIB_PROVIDER` | `portkey` | Gateway to route through: `portkey` or `anthropic`. |
|
|
49
|
+
| `LABS_AGENT_LIB_LOG_LEVEL` | `INFO` | `DEBUG`, `INFO`, `WARNING`, or `ERROR`. |
|
|
50
|
+
| `LABS_AGENT_LIB_PORTKEY_BASE_URL` | `https://portkeygateway.perficient.com` | Portkey gateway base URL. |
|
|
51
|
+
| `LABS_AGENT_LIB_PORTKEY_API_KEY` | `""` | Portkey API key (secret). |
|
|
52
|
+
| `LABS_AGENT_LIB_PORTKEY_VIRTUAL` | `""` | Provider/credential alias from the Portkey dashboard (e.g. `aws-bedrock-use2`). |
|
|
53
|
+
| `LABS_AGENT_LIB_ANTHROPIC_BASE_URL` | `https://api.anthropic.com` | Anthropic API base URL. |
|
|
54
|
+
| `LABS_AGENT_LIB_ANTHROPIC_API_KEY` | `""` | Anthropic API key (secret). |
|
|
55
|
+
| `LABS_AGENT_LIB_DISABLE_PROMPT_CACHING` | `false` | Disable Anthropic prompt caching, e.g. while iterating on a system prompt. |
|
|
56
|
+
|
|
57
|
+
Example `.env`:
|
|
58
|
+
|
|
59
|
+
```dotenv
|
|
60
|
+
LABS_AGENT_LIB_PROVIDER=anthropic
|
|
61
|
+
LABS_AGENT_LIB_ANTHROPIC_API_KEY=sk-ant-...
|
|
62
|
+
```
|
|
63
|
+
|
|
64
|
+
## Usage
|
|
65
|
+
|
|
66
|
+
```python
|
|
67
|
+
from labs_agent_lib import get_logger, get_agent_settings, get_agent_environment
|
|
68
|
+
from labs_agent_lib.file_reader import extract_text
|
|
69
|
+
|
|
70
|
+
logger = get_logger(__name__)
|
|
71
|
+
settings = get_agent_settings()
|
|
72
|
+
env = get_agent_environment(settings)
|
|
73
|
+
text = extract_text("resume.pdf")
|
|
74
|
+
```
|
|
75
|
+
|
|
76
|
+
## Development
|
|
77
|
+
|
|
78
|
+
```bash
|
|
79
|
+
uv sync
|
|
80
|
+
uv run pytest
|
|
81
|
+
```
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
labs_agent_lib/__init__.py,sha256=itGrZzRqTvUlxwEkddnJVBs-NNfB6dc0n2pV2HNZQxM,475
|
|
2
|
+
labs_agent_lib/agent_environment/__init__.py,sha256=RqUGGPuQemne07DTBONw8_rKkJ4bK42_JiYa6l6OMK4,327
|
|
3
|
+
labs_agent_lib/agent_environment/agent_environment.py,sha256=FUJLmoTUQKqNEAcozQlwm4rwWI_gh5M8BlJrCdgcfkI,2066
|
|
4
|
+
labs_agent_lib/agent_settings.py,sha256=KqWHUhgAfwNIB1TBAH1yPriGFUBHS7MWKC_mUQRjUPA,1838
|
|
5
|
+
labs_agent_lib/file_reader/__init__.py,sha256=o1Hvsys1Ex74IRKGJ6e6T8MQHQflyhwuGHMtl1DwxGg,198
|
|
6
|
+
labs_agent_lib/file_reader/docx_reader.py,sha256=XiUjfadt7Z4wbfWJjVtLlV8_SMmJg8v8-ZkxmRa5PuA,251
|
|
7
|
+
labs_agent_lib/file_reader/errors.py,sha256=iHtLVT2Tu2yr0FV3cY4MAFkJvKVSQvUhodRDDu3XgzM,463
|
|
8
|
+
labs_agent_lib/file_reader/extractor.py,sha256=_4aUf_TDdSZik_n-cxsGFku9qT0mnWknOLkSo3coC3A,1011
|
|
9
|
+
labs_agent_lib/file_reader/pdf_reader.py,sha256=vOLRGO3AxY_2-wtNPFJbB1O5diTeuGCfSKGNjZzAr_I,308
|
|
10
|
+
labs_agent_lib/file_reader/text_reader.py,sha256=rQJSLG8DEH3vp-9oBSqMfXXqDFJoe0ZIo8sRzY9QIGA,266
|
|
11
|
+
labs_agent_lib/file_writer/__init__.py,sha256=d_Rcex8-RuJWAuHNV2qf1tVzGOGLoU31srhodlDChRE,137
|
|
12
|
+
labs_agent_lib/file_writer/resume_mover.py,sha256=DnUv0ZySzPjIuCVD_GDHDWM63w35afw90l4nNhIPAZs,440
|
|
13
|
+
labs_agent_lib/logger.py,sha256=NQW791rUg5_4p_F1lze0Qs5mHQya7rlqgMeAyUSttTw,1588
|
|
14
|
+
labs_agent_lib/observability/__init__.py,sha256=6IDRJEpHljjZiuV8NGpsD6qvU57dSsaEjz067tVTczw,478
|
|
15
|
+
labs_agent_lib/observability/_jsonl_writer.py,sha256=uoRuCte8GrLuymS4TcqTgXzcBvhXWbwQnZDsStH2xXI,1303
|
|
16
|
+
labs_agent_lib/observability/agent_transcript.py,sha256=SzCUOkQ5ASRYRRC2JdryogK6ovHzCqBsOavenQ7DpQw,5386
|
|
17
|
+
labs_agent_lib/observability/prompt_log.py,sha256=dkVz-qrwEkBLn4q4Pi4Bx5m5NoMOJv1syapesfWlewE,2229
|
|
18
|
+
labs_agent_lib-0.1.0.dist-info/licenses/LICENSE,sha256=IGiwkyXXQSAmCP3-U_MxnvtRsVwAdr_QE8JtR_gYW40,1107
|
|
19
|
+
labs_agent_lib-0.1.0.dist-info/WHEEL,sha256=q9Wah2_CgP24mRNSlTyb0F8Yrbz55SzVl8eHLkZ_wvE,80
|
|
20
|
+
labs_agent_lib-0.1.0.dist-info/METADATA,sha256=cwf5b63xRwOoR5HCubO5wQxUR4rNVFeyAEuOSeym0xo,3317
|
|
21
|
+
labs_agent_lib-0.1.0.dist-info/RECORD,,
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Luis Alberto Bolaños Salazar
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|