293 lines
9.7 KiB
Python
293 lines
9.7 KiB
Python
"""Agent graph construction for Job Hunter Agent."""
|
|
|
|
from pathlib import Path
|
|
|
|
from framework.graph import EdgeSpec, EdgeCondition, Goal, SuccessCriterion, Constraint
|
|
from framework.graph.edge import GraphSpec
|
|
from framework.graph.executor import ExecutionResult
|
|
from framework.graph.checkpoint_config import CheckpointConfig
|
|
from framework.llm import LiteLLMProvider
|
|
from framework.runner.tool_registry import ToolRegistry
|
|
from framework.runtime.agent_runtime import AgentRuntime, create_agent_runtime
|
|
from framework.runtime.execution_stream import EntryPointSpec
|
|
|
|
from .config import default_config
|
|
from .nodes import (
|
|
intake_node,
|
|
job_search_node,
|
|
job_review_node,
|
|
customize_node,
|
|
)
|
|
|
|
# Goal definition
|
|
goal = Goal(
|
|
id="job-hunter",
|
|
name="Job Hunter",
|
|
description=(
|
|
"Analyze a user's resume to identify their strongest role fits, find 10 "
|
|
"matching job opportunities, let the user select which to pursue, then "
|
|
"generate a resume customization list and cold outreach email for each selected job."
|
|
),
|
|
success_criteria=[
|
|
SuccessCriterion(
|
|
id="role-identification",
|
|
description="Identifies 2-3 role types that genuinely match the user's experience",
|
|
metric="role_match_accuracy",
|
|
target=">=0.8",
|
|
weight=0.2,
|
|
),
|
|
SuccessCriterion(
|
|
id="job-relevance",
|
|
description="Found jobs align with identified roles and user's background",
|
|
metric="job_relevance_score",
|
|
target=">=0.8",
|
|
weight=0.2,
|
|
),
|
|
SuccessCriterion(
|
|
id="customization-quality",
|
|
description="Resume changes are specific, actionable, and tailored to each job posting",
|
|
metric="customization_specificity",
|
|
target=">=0.85",
|
|
weight=0.25,
|
|
),
|
|
SuccessCriterion(
|
|
id="email-effectiveness",
|
|
description="Cold emails are personalized, professional, and reference specific company/role details",
|
|
metric="email_personalization_score",
|
|
target=">=0.85",
|
|
weight=0.2,
|
|
),
|
|
SuccessCriterion(
|
|
id="user-satisfaction",
|
|
description="User approves outputs without major revisions needed",
|
|
metric="approval_rate",
|
|
target=">=0.9",
|
|
weight=0.15,
|
|
),
|
|
],
|
|
constraints=[
|
|
Constraint(
|
|
id="realistic-roles",
|
|
description="Only suggest roles the user is realistically qualified for - no aspirational stretch roles",
|
|
constraint_type="quality",
|
|
category="accuracy",
|
|
),
|
|
Constraint(
|
|
id="truthful-customizations",
|
|
description="Resume customizations must be truthful - enhance presentation, never fabricate experience",
|
|
constraint_type="ethical",
|
|
category="integrity",
|
|
),
|
|
Constraint(
|
|
id="professional-emails",
|
|
description="Cold emails must be professional and not spammy",
|
|
constraint_type="quality",
|
|
category="tone",
|
|
),
|
|
Constraint(
|
|
id="respect-selection",
|
|
description="Only customize for jobs the user explicitly selects",
|
|
constraint_type="behavioral",
|
|
category="user_control",
|
|
),
|
|
],
|
|
)
|
|
|
|
# Node list
|
|
nodes = [
|
|
intake_node,
|
|
job_search_node,
|
|
job_review_node,
|
|
customize_node,
|
|
]
|
|
|
|
# Edge definitions
|
|
edges = [
|
|
# intake -> job-search
|
|
EdgeSpec(
|
|
id="intake-to-job-search",
|
|
source="intake",
|
|
target="job-search",
|
|
condition=EdgeCondition.ON_SUCCESS,
|
|
priority=1,
|
|
),
|
|
# job-search -> job-review
|
|
EdgeSpec(
|
|
id="job-search-to-job-review",
|
|
source="job-search",
|
|
target="job-review",
|
|
condition=EdgeCondition.ON_SUCCESS,
|
|
priority=1,
|
|
),
|
|
# job-review -> customize
|
|
EdgeSpec(
|
|
id="job-review-to-customize",
|
|
source="job-review",
|
|
target="customize",
|
|
condition=EdgeCondition.ON_SUCCESS,
|
|
priority=1,
|
|
),
|
|
]
|
|
|
|
# Graph configuration
|
|
entry_node = "intake"
|
|
entry_points = {"start": "intake"}
|
|
pause_nodes = []
|
|
terminal_nodes = ["customize"]
|
|
|
|
|
|
class JobHunterAgent:
|
|
"""
|
|
Job Hunter Agent — 4-node pipeline for job search and application materials.
|
|
"""
|
|
|
|
def __init__(self, config=None):
|
|
self.config = config or default_config
|
|
self.goal = goal
|
|
self.nodes = nodes
|
|
self.edges = edges
|
|
self.entry_node = entry_node
|
|
self.entry_points = entry_points
|
|
self.pause_nodes = pause_nodes
|
|
self.terminal_nodes = terminal_nodes
|
|
self._graph: GraphSpec | None = None
|
|
self._agent_runtime: AgentRuntime | None = None
|
|
self._tool_registry: ToolRegistry | None = None
|
|
self._storage_path: Path | None = None
|
|
|
|
def _build_graph(self) -> GraphSpec:
|
|
"""Build the GraphSpec."""
|
|
return GraphSpec(
|
|
id="job-hunter-graph",
|
|
goal_id=self.goal.id,
|
|
version="1.1.0",
|
|
entry_node=self.entry_node,
|
|
entry_points=self.entry_points,
|
|
terminal_nodes=self.terminal_nodes,
|
|
pause_nodes=self.pause_nodes,
|
|
nodes=self.nodes,
|
|
edges=self.edges,
|
|
default_model=self.config.model,
|
|
max_tokens=self.config.max_tokens,
|
|
loop_config={
|
|
"max_iterations": 100,
|
|
"max_tool_calls_per_turn": 30,
|
|
"max_history_tokens": 32000,
|
|
},
|
|
conversation_mode="continuous",
|
|
identity_prompt=(
|
|
"You are a job hunting assistant. You analyze resumes to identify "
|
|
"the strongest role fits, search for matching job opportunities, "
|
|
"and help create personalized application materials."
|
|
),
|
|
)
|
|
|
|
def _setup(self, mock_mode=False) -> None:
|
|
"""Set up the agent runtime with sessions, checkpoints, and logging."""
|
|
self._storage_path = Path.home() / ".hive" / "agents" / "job_hunter"
|
|
self._storage_path.mkdir(parents=True, exist_ok=True)
|
|
|
|
self._tool_registry = ToolRegistry()
|
|
|
|
mcp_config_path = Path(__file__).parent / "mcp_servers.json"
|
|
if mcp_config_path.exists():
|
|
self._tool_registry.load_mcp_config(mcp_config_path)
|
|
|
|
llm = None
|
|
if not mock_mode:
|
|
llm = LiteLLMProvider(
|
|
model=self.config.model,
|
|
api_key=self.config.api_key,
|
|
api_base=self.config.api_base,
|
|
)
|
|
|
|
tool_executor = self._tool_registry.get_executor()
|
|
tools = list(self._tool_registry.get_tools().values())
|
|
|
|
self._graph = self._build_graph()
|
|
|
|
checkpoint_config = CheckpointConfig(
|
|
enabled=True,
|
|
checkpoint_on_node_start=False,
|
|
checkpoint_on_node_complete=True,
|
|
checkpoint_max_age_days=7,
|
|
async_checkpoint=True,
|
|
)
|
|
|
|
entry_point_specs = [
|
|
EntryPointSpec(
|
|
id="default",
|
|
name="Default",
|
|
entry_node=self.entry_node,
|
|
trigger_type="manual",
|
|
isolation_level="shared",
|
|
)
|
|
]
|
|
|
|
self._agent_runtime = create_agent_runtime(
|
|
graph=self._graph,
|
|
goal=self.goal,
|
|
storage_path=self._storage_path,
|
|
entry_points=entry_point_specs,
|
|
llm=llm,
|
|
tools=tools,
|
|
tool_executor=tool_executor,
|
|
checkpoint_config=checkpoint_config,
|
|
)
|
|
|
|
async def start(self, mock_mode=False) -> None:
|
|
"""Set up and start the agent runtime."""
|
|
if self._agent_runtime is None:
|
|
self._setup(mock_mode=mock_mode)
|
|
if not self._agent_runtime.is_running:
|
|
await self._agent_runtime.start()
|
|
|
|
async def stop(self) -> None:
|
|
"""Stop the agent runtime and clean up."""
|
|
if self._agent_runtime and self._agent_runtime.is_running:
|
|
await self._agent_runtime.stop()
|
|
self._agent_runtime = None
|
|
|
|
async def trigger_and_wait(
|
|
self,
|
|
entry_point: str = "default",
|
|
input_data: dict | None = None,
|
|
timeout: float | None = None,
|
|
session_state: dict | None = None,
|
|
) -> ExecutionResult | None:
|
|
"""Execute the graph and wait for completion."""
|
|
if self._agent_runtime is None:
|
|
raise RuntimeError("Agent not started. Call start() first.")
|
|
|
|
return await self._agent_runtime.trigger_and_wait(
|
|
entry_point_id=entry_point,
|
|
input_data=input_data or {},
|
|
session_state=session_state,
|
|
)
|
|
|
|
async def run(self, context: dict, mock_mode=False, session_state=None) -> ExecutionResult:
|
|
"""Run the agent (convenience method for single execution)."""
|
|
await self.start(mock_mode=mock_mode)
|
|
try:
|
|
result = await self.trigger_and_wait("default", context, session_state=session_state)
|
|
return result or ExecutionResult(success=False, error="Execution timeout")
|
|
finally:
|
|
await self.stop()
|
|
|
|
def validate(self):
|
|
"""Validate agent structure."""
|
|
errors = []
|
|
node_ids = {node.id for node in self.nodes}
|
|
for edge in self.edges:
|
|
if edge.source not in node_ids:
|
|
errors.append(f"Edge {edge.id}: source '{edge.source}' not found")
|
|
if edge.target not in node_ids:
|
|
errors.append(f"Edge {edge.id}: target '{edge.target}' not found")
|
|
if self.entry_node not in node_ids:
|
|
errors.append(f"Entry node '{self.entry_node}' not found")
|
|
return {"valid": len(errors) == 0, "errors": errors}
|
|
|
|
|
|
# Create default instance
|
|
default_agent = JobHunterAgent()
|