services.py 33 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894
  1. from datetime import datetime, timedelta
  2. from typing import cast
  3. from sqlalchemy.orm import Session
  4. from core_domain import (
  5. AgentSkillRefContract,
  6. AgentToolRefContract,
  7. ChatCompletionRequestContract,
  8. ChatMessageContract,
  9. MemoryCreateContract,
  10. MemoryScopeType,
  11. MemorySearchRequestContract,
  12. MemorySearchResultContract,
  13. )
  14. from core_shared import JSONValue
  15. from app.bootstrap.settings import AgentServiceSettings
  16. from app.db.models import AgentDefinition, AgentRun, AgentVersion
  17. from app.domain.repositories import (
  18. AgentDefinitionRepository,
  19. AgentRunRepository,
  20. AgentVersionRepository,
  21. )
  22. from app.infrastructure.model_gateway_client import ModelGatewayClient, ModelGatewayClientError
  23. from app.infrastructure.memory_client import MemoryClient, MemoryClientError
  24. from app.infrastructure.skill_client import SkillServiceClient, SkillServiceClientError
  25. from app.infrastructure.tool_client import ToolServiceClient, ToolServiceClientError
  26. from app.schemas.agent import (
  27. AgentCreateRequest,
  28. AgentRunCreateRequest,
  29. AgentRunExecuteRequest,
  30. AgentRunStatusUpdateRequest,
  31. AgentStatusUpdateRequest,
  32. AgentVersionCreateRequest,
  33. )
  34. class AgentApplicationService:
  35. def __init__(
  36. self,
  37. *,
  38. agent_repository: AgentDefinitionRepository,
  39. agent_version_repository: AgentVersionRepository,
  40. agent_run_repository: AgentRunRepository,
  41. model_gateway_client: ModelGatewayClient | None = None,
  42. memory_client: MemoryClient | None = None,
  43. tool_client: ToolServiceClient | None = None,
  44. skill_client: SkillServiceClient | None = None,
  45. ) -> None:
  46. self.agent_repository = agent_repository
  47. self.agent_version_repository = agent_version_repository
  48. self.agent_run_repository = agent_run_repository
  49. self.model_gateway_client = model_gateway_client
  50. self.memory_client = memory_client
  51. self.tool_client = tool_client
  52. self.skill_client = skill_client
  53. def create_agent(self, payload: AgentCreateRequest) -> AgentDefinition:
  54. return self.agent_repository.create(
  55. tenant_id=payload.tenant_id,
  56. code=payload.code,
  57. name=payload.name,
  58. description=payload.description,
  59. agent_type=payload.agent_type,
  60. owner_user_id=payload.owner_user_id,
  61. metadata_json=payload.metadata_json,
  62. )
  63. def list_agents(self, *, tenant_id: str) -> list[AgentDefinition]:
  64. return self.agent_repository.list_by_tenant(tenant_id=tenant_id)
  65. def update_agent_status(
  66. self,
  67. *,
  68. agent_id: str,
  69. payload: AgentStatusUpdateRequest,
  70. ) -> AgentDefinition | None:
  71. return self.agent_repository.update_status(
  72. tenant_id=payload.tenant_id,
  73. agent_id=agent_id,
  74. status=payload.status,
  75. )
  76. def create_agent_version(self, payload: AgentVersionCreateRequest) -> AgentVersion:
  77. agent = self.agent_repository.get_by_id(
  78. tenant_id=payload.tenant_id,
  79. agent_id=payload.agent_id,
  80. )
  81. if agent is None:
  82. raise ValueError(f"agent not found: {payload.agent_id}")
  83. return self.agent_version_repository.create(
  84. tenant_id=payload.tenant_id,
  85. agent_id=payload.agent_id,
  86. status=payload.status,
  87. role=payload.role,
  88. goal=payload.goal,
  89. system_prompt=payload.system_prompt,
  90. model_config_json=payload.model_config_data.model_dump(mode="json"),
  91. memory_policy_json=payload.memory_policy.model_dump(mode="json"),
  92. tool_refs_json=[item.model_dump(mode="json") for item in payload.tool_refs],
  93. skill_refs_json=[item.model_dump(mode="json") for item in payload.skill_refs],
  94. )
  95. def list_agent_versions(self, *, tenant_id: str, agent_id: str) -> list[AgentVersion]:
  96. return self.agent_version_repository.list_by_agent(tenant_id=tenant_id, agent_id=agent_id)
  97. def create_agent_run(self, payload: AgentRunCreateRequest) -> AgentRun:
  98. agent_version = self._resolve_agent_version(
  99. tenant_id=payload.tenant_id,
  100. agent_id=payload.agent_id,
  101. agent_version_id=payload.agent_version_id,
  102. )
  103. if agent_version is None:
  104. raise ValueError("published agent version not found")
  105. return self.agent_run_repository.create(
  106. tenant_id=payload.tenant_id,
  107. agent_id=payload.agent_id,
  108. agent_version_id=agent_version.id,
  109. session_id=payload.session_id,
  110. input_text=payload.input_text,
  111. input_json=payload.input_json,
  112. )
  113. def list_agent_runs(
  114. self,
  115. *,
  116. tenant_id: str,
  117. agent_id: str | None = None,
  118. session_id: str | None = None,
  119. ) -> list[AgentRun]:
  120. return self.agent_run_repository.list_by_scope(
  121. tenant_id=tenant_id,
  122. agent_id=agent_id,
  123. session_id=session_id,
  124. )
  125. def update_agent_run_status(
  126. self,
  127. *,
  128. agent_run_id: str,
  129. payload: AgentRunStatusUpdateRequest,
  130. ) -> AgentRun | None:
  131. entity = self.agent_run_repository.get_by_id(
  132. tenant_id=payload.tenant_id,
  133. agent_run_id=agent_run_id,
  134. )
  135. if entity is None:
  136. return None
  137. return self.agent_run_repository.update_status(
  138. agent_run_id=agent_run_id,
  139. status=payload.status,
  140. worker_key=payload.worker_key,
  141. output_text=payload.output_text,
  142. output_json=payload.output_json,
  143. error_code=payload.error_code,
  144. error_message=payload.error_message,
  145. )
  146. def execute_agent_run(
  147. self,
  148. *,
  149. agent_run_id: str,
  150. payload: AgentRunExecuteRequest,
  151. ) -> AgentRun | None:
  152. agent_run = self.agent_run_repository.get_by_id(
  153. tenant_id=payload.tenant_id,
  154. agent_run_id=agent_run_id,
  155. )
  156. if agent_run is None:
  157. return None
  158. agent_version = self.agent_version_repository.get_by_id(
  159. tenant_id=payload.tenant_id,
  160. agent_version_id=agent_run.agent_version_id,
  161. )
  162. if agent_version is None:
  163. return self.agent_run_repository.update_status(
  164. agent_run_id=agent_run.id,
  165. status="failed",
  166. worker_key=payload.worker_key,
  167. error_code="agent_version_missing",
  168. error_message=f"agent version not found: {agent_run.agent_version_id}",
  169. )
  170. self.agent_run_repository.update_status(
  171. agent_run_id=agent_run.id,
  172. status="running",
  173. worker_key=payload.worker_key,
  174. )
  175. memory_results, memory_metadata = self._read_relevant_memories(
  176. agent_run=agent_run,
  177. agent_version=agent_version,
  178. )
  179. selected_tools = self._select_tool_refs(agent_run=agent_run, agent_version=agent_version)
  180. selected_skills = self._select_skill_refs(agent_run=agent_run, agent_version=agent_version)
  181. if payload.dry_run:
  182. messages = self._build_chat_messages(
  183. agent_run=agent_run,
  184. agent_version=agent_version,
  185. memory_results=memory_results,
  186. capability_context=self._format_capability_plan(
  187. selected_tools=selected_tools,
  188. selected_skills=selected_skills,
  189. ),
  190. )
  191. return self.agent_run_repository.update_status(
  192. agent_run_id=agent_run.id,
  193. status="completed",
  194. worker_key=payload.worker_key,
  195. output_text=self._build_dry_run_output(
  196. agent_run=agent_run,
  197. agent_version=agent_version,
  198. ),
  199. output_json={
  200. "dry_run": True,
  201. "agent_version_id": agent_version.id,
  202. "message_count": len(messages),
  203. "messages": [message.model_dump(mode="json") for message in messages],
  204. "selected_tool_refs": [
  205. tool_ref.model_dump(mode="json") for tool_ref in selected_tools
  206. ],
  207. "selected_skill_refs": [
  208. skill_ref.model_dump(mode="json") for skill_ref in selected_skills
  209. ],
  210. **memory_metadata,
  211. },
  212. )
  213. tool_invocations = self._invoke_selected_tools(
  214. agent_run=agent_run,
  215. selected_tools=selected_tools,
  216. )
  217. skill_invocations = self._invoke_selected_skills(
  218. agent_run=agent_run,
  219. selected_skills=selected_skills,
  220. worker_key=payload.worker_key,
  221. )
  222. messages = self._build_chat_messages(
  223. agent_run=agent_run,
  224. agent_version=agent_version,
  225. memory_results=memory_results,
  226. capability_context=self._format_capability_results(
  227. tool_invocations=tool_invocations,
  228. skill_invocations=skill_invocations,
  229. ),
  230. )
  231. if self.model_gateway_client is None:
  232. return self.agent_run_repository.update_status(
  233. agent_run_id=agent_run.id,
  234. status="failed",
  235. worker_key=payload.worker_key,
  236. error_code="model_gateway_missing",
  237. error_message="model gateway client is not configured",
  238. output_json={
  239. "tool_invocations": tool_invocations,
  240. "skill_invocations": skill_invocations,
  241. **memory_metadata,
  242. },
  243. )
  244. try:
  245. response = self.model_gateway_client.create_chat_completion(
  246. ChatCompletionRequestContract(
  247. model=self._read_optional_string(agent_version.model_config_json, "model"),
  248. temperature=self._read_optional_float(
  249. agent_version.model_config_json,
  250. "temperature",
  251. ),
  252. max_tokens=self._read_optional_int(agent_version.model_config_json, "max_tokens"),
  253. messages=messages,
  254. metadata_json={
  255. "tenant_id": agent_run.tenant_id,
  256. "agent_id": agent_run.agent_id,
  257. "agent_version_id": agent_version.id,
  258. "agent_run_id": agent_run.id,
  259. },
  260. )
  261. )
  262. except ModelGatewayClientError as exc:
  263. return self.agent_run_repository.update_status(
  264. agent_run_id=agent_run.id,
  265. status="failed",
  266. worker_key=payload.worker_key,
  267. error_code="model_gateway_error",
  268. error_message=str(exc),
  269. )
  270. memory_write_metadata = self._write_interaction_memory(
  271. agent_run=agent_run,
  272. agent_version=agent_version,
  273. output_text=response.content,
  274. )
  275. return self.agent_run_repository.update_status(
  276. agent_run_id=agent_run.id,
  277. status="completed",
  278. worker_key=payload.worker_key,
  279. output_text=response.content,
  280. output_json={
  281. "dry_run": False,
  282. "agent_version_id": agent_version.id,
  283. "model": response.model,
  284. "finish_reason": response.finish_reason,
  285. "usage_json": response.usage_json,
  286. "raw_response_json": response.raw_response_json,
  287. "tool_invocations": tool_invocations,
  288. "skill_invocations": skill_invocations,
  289. **memory_metadata,
  290. **memory_write_metadata,
  291. },
  292. )
  293. def execute_next_claimed_agent_run(
  294. self,
  295. *,
  296. worker_key: str,
  297. lease_seconds: int,
  298. dry_run: bool,
  299. ) -> tuple[AgentRun, int] | None:
  300. released_lease_count = self.agent_run_repository.release_expired_leases(
  301. now_time=datetime.utcnow(),
  302. )
  303. claimed_agent_run = self.agent_run_repository.claim_next_queued(
  304. worker_key=worker_key,
  305. lease_expire_time=datetime.utcnow() + timedelta(seconds=lease_seconds),
  306. )
  307. if claimed_agent_run is None:
  308. return None
  309. result = self.execute_agent_run(
  310. agent_run_id=claimed_agent_run.id,
  311. payload=AgentRunExecuteRequest(
  312. tenant_id=claimed_agent_run.tenant_id,
  313. worker_key=worker_key,
  314. dry_run=dry_run,
  315. ),
  316. )
  317. if result is None:
  318. return None
  319. return result, released_lease_count
  320. def _resolve_agent_version(
  321. self,
  322. *,
  323. tenant_id: str,
  324. agent_id: str,
  325. agent_version_id: str | None,
  326. ) -> AgentVersion | None:
  327. if agent_version_id is not None:
  328. return self.agent_version_repository.get_by_id(
  329. tenant_id=tenant_id,
  330. agent_version_id=agent_version_id,
  331. )
  332. return self.agent_version_repository.get_latest_published(
  333. tenant_id=tenant_id,
  334. agent_id=agent_id,
  335. )
  336. def _build_chat_messages(
  337. self,
  338. *,
  339. agent_run: AgentRun,
  340. agent_version: AgentVersion,
  341. memory_results: list[MemorySearchResultContract] | None = None,
  342. capability_context: str | None = None,
  343. ) -> list[ChatMessageContract]:
  344. messages = [
  345. ChatMessageContract(role="system", content=agent_version.system_prompt),
  346. ]
  347. if agent_version.goal:
  348. messages.append(ChatMessageContract(role="system", content=f"Goal: {agent_version.goal}"))
  349. if memory_results:
  350. messages.append(
  351. ChatMessageContract(
  352. role="system",
  353. content=self._format_memory_context(memory_results),
  354. )
  355. )
  356. if capability_context:
  357. messages.append(ChatMessageContract(role="system", content=capability_context))
  358. if agent_run.input_text:
  359. messages.append(ChatMessageContract(role="user", content=agent_run.input_text))
  360. if agent_run.input_json:
  361. messages.append(
  362. ChatMessageContract(
  363. role="user",
  364. content=f"Structured input: {agent_run.input_json}",
  365. )
  366. )
  367. return messages
  368. def _select_tool_refs(
  369. self,
  370. *,
  371. agent_run: AgentRun,
  372. agent_version: AgentVersion,
  373. ) -> list[AgentToolRefContract]:
  374. input_preview = self._build_input_preview(agent_run)
  375. selected: list[AgentToolRefContract] = []
  376. for item in agent_version.tool_refs_json:
  377. ref = AgentToolRefContract.model_validate(item)
  378. if (
  379. ref.required
  380. or self._read_bool(ref.config_json, "auto_invoke", default=False)
  381. or self._matches_selection_keywords(ref.config_json, input_preview)
  382. ):
  383. selected.append(ref)
  384. return selected
  385. def _select_skill_refs(
  386. self,
  387. *,
  388. agent_run: AgentRun,
  389. agent_version: AgentVersion,
  390. ) -> list[AgentSkillRefContract]:
  391. input_preview = self._build_input_preview(agent_run)
  392. selected: list[AgentSkillRefContract] = []
  393. for item in agent_version.skill_refs_json:
  394. ref = AgentSkillRefContract.model_validate(item)
  395. auto_invoke = self._read_bool(ref.config_json, "auto_invoke", default=True)
  396. if auto_invoke or self._matches_selection_keywords(ref.config_json, input_preview):
  397. selected.append(ref)
  398. return selected
  399. def _invoke_selected_tools(
  400. self,
  401. *,
  402. agent_run: AgentRun,
  403. selected_tools: list[AgentToolRefContract],
  404. ) -> list[dict[str, JSONValue]]:
  405. invocations: list[dict[str, JSONValue]] = []
  406. for ref in selected_tools:
  407. if ref.tool_binding_id is None:
  408. invocations.append(
  409. {
  410. "status": "skipped",
  411. "reason": "tool_binding_id_missing",
  412. "tool_code": ref.tool_code,
  413. }
  414. )
  415. continue
  416. if self.tool_client is None:
  417. invocations.append(
  418. {
  419. "status": "failed",
  420. "reason": "tool_client_missing",
  421. "tool_binding_id": ref.tool_binding_id,
  422. }
  423. )
  424. continue
  425. try:
  426. detail = self.tool_client.get_tool_binding_detail(
  427. tenant_id=agent_run.tenant_id,
  428. binding_id=ref.tool_binding_id,
  429. )
  430. if not detail.binding.enabled:
  431. invocations.append(
  432. {
  433. "status": "failed",
  434. "reason": "tool_binding_disabled",
  435. "tool_binding_id": ref.tool_binding_id,
  436. }
  437. )
  438. continue
  439. if detail.tool_definition.tool_type != "http":
  440. invocations.append(
  441. {
  442. "status": "skipped",
  443. "reason": "unsupported_tool_type",
  444. "tool_type": detail.tool_definition.tool_type,
  445. "tool_binding_id": ref.tool_binding_id,
  446. }
  447. )
  448. continue
  449. output_text, output_json = self.tool_client.invoke_http_tool(
  450. detail=detail,
  451. input_json=agent_run.input_json or {},
  452. config_json=ref.config_json,
  453. )
  454. except ToolServiceClientError as exc:
  455. invocations.append(
  456. {
  457. "status": "failed",
  458. "reason": str(exc),
  459. "tool_binding_id": ref.tool_binding_id,
  460. }
  461. )
  462. continue
  463. invocations.append(
  464. {
  465. "status": "completed",
  466. "tool_binding_id": ref.tool_binding_id,
  467. "tool_code": detail.tool_definition.code,
  468. "output_text": output_text,
  469. "output_json": output_json,
  470. }
  471. )
  472. return invocations
  473. def _invoke_selected_skills(
  474. self,
  475. *,
  476. agent_run: AgentRun,
  477. selected_skills: list[AgentSkillRefContract],
  478. worker_key: str | None,
  479. ) -> list[dict[str, JSONValue]]:
  480. invocations: list[dict[str, JSONValue]] = []
  481. for ref in selected_skills:
  482. if self.skill_client is None:
  483. invocations.append(
  484. {
  485. "status": "failed",
  486. "reason": "skill_client_missing",
  487. "skill_id": ref.skill_id,
  488. "skill_code": ref.skill_code,
  489. }
  490. )
  491. continue
  492. skill_id = ref.skill_id or self._resolve_skill_id_by_code(
  493. tenant_id=agent_run.tenant_id,
  494. skill_code=ref.skill_code,
  495. )
  496. if skill_id is None:
  497. invocations.append(
  498. {
  499. "status": "failed",
  500. "reason": "skill_id_missing",
  501. "skill_code": ref.skill_code,
  502. }
  503. )
  504. continue
  505. try:
  506. created_run = self.skill_client.create_skill_run(
  507. tenant_id=agent_run.tenant_id,
  508. skill_id=skill_id,
  509. skill_version_id=self._read_optional_string(
  510. ref.config_json,
  511. "skill_version_id",
  512. ),
  513. installation_id=self._read_optional_string(
  514. ref.config_json,
  515. "installation_id",
  516. ),
  517. input_json=self._build_skill_input_json(agent_run=agent_run, ref=ref),
  518. )
  519. executed_run = self.skill_client.execute_skill_run(
  520. tenant_id=agent_run.tenant_id,
  521. skill_run_id=created_run.id,
  522. worker_key=worker_key,
  523. )
  524. except SkillServiceClientError as exc:
  525. invocations.append(
  526. {
  527. "status": "failed",
  528. "reason": str(exc),
  529. "skill_id": skill_id,
  530. "skill_code": ref.skill_code,
  531. }
  532. )
  533. continue
  534. invocations.append(
  535. {
  536. "status": executed_run.status,
  537. "skill_id": skill_id,
  538. "skill_code": ref.skill_code,
  539. "skill_run_id": executed_run.id,
  540. "output_text": executed_run.output_text,
  541. "output_json": executed_run.output_json,
  542. "error_code": executed_run.error_code,
  543. "error_message": executed_run.error_message,
  544. }
  545. )
  546. return invocations
  547. def _format_capability_plan(
  548. self,
  549. *,
  550. selected_tools: list[AgentToolRefContract],
  551. selected_skills: list[AgentSkillRefContract],
  552. ) -> str:
  553. return (
  554. "Selected capability plan before model call:\n"
  555. f"Tools: {[item.model_dump(mode='json') for item in selected_tools]}\n"
  556. f"Skills: {[item.model_dump(mode='json') for item in selected_skills]}"
  557. )
  558. def _format_capability_results(
  559. self,
  560. *,
  561. tool_invocations: list[dict[str, JSONValue]],
  562. skill_invocations: list[dict[str, JSONValue]],
  563. ) -> str | None:
  564. if not tool_invocations and not skill_invocations:
  565. return None
  566. return (
  567. "Capability invocation results before model call:\n"
  568. f"Tools: {tool_invocations}\n"
  569. f"Skills: {skill_invocations}"
  570. )
  571. def _build_skill_input_json(
  572. self,
  573. *,
  574. agent_run: AgentRun,
  575. ref: AgentSkillRefContract,
  576. ) -> dict[str, JSONValue]:
  577. input_json: dict[str, JSONValue] = dict(agent_run.input_json or {})
  578. if agent_run.input_text:
  579. input_json.setdefault("input_text", agent_run.input_text)
  580. configured_input = ref.config_json.get("input_json")
  581. if isinstance(configured_input, dict):
  582. input_json.update(
  583. {str(item_key): item_value for item_key, item_value in configured_input.items()}
  584. )
  585. return input_json
  586. def _resolve_skill_id_by_code(self, *, tenant_id: str, skill_code: str | None) -> str | None:
  587. if skill_code is None or self.skill_client is None:
  588. return None
  589. try:
  590. skills = self.skill_client.list_skills(tenant_id=tenant_id)
  591. except SkillServiceClientError:
  592. return None
  593. for skill in skills:
  594. if skill.code == skill_code:
  595. return skill.id
  596. return None
  597. def _build_input_preview(self, agent_run: AgentRun) -> str:
  598. return f"{agent_run.input_text or ''} {agent_run.input_json or {}}".lower()
  599. def _matches_selection_keywords(
  600. self,
  601. config_json: dict[str, JSONValue],
  602. input_preview: str,
  603. ) -> bool:
  604. keywords = config_json.get("selection_keywords")
  605. if not isinstance(keywords, list):
  606. return False
  607. return any(
  608. isinstance(keyword, str) and keyword.lower() in input_preview
  609. for keyword in keywords
  610. )
  611. def _build_dry_run_output(self, *, agent_run: AgentRun, agent_version: AgentVersion) -> str:
  612. input_preview = agent_run.input_text or str(agent_run.input_json or {})
  613. return (
  614. f"[dry-run] Agent role={agent_version.role} "
  615. f"version={agent_version.version_no} received: {input_preview}"
  616. )
  617. def _read_optional_string(self, payload: dict[str, JSONValue], key: str) -> str | None:
  618. value = payload.get(key)
  619. if isinstance(value, str) and value:
  620. return value
  621. return None
  622. def _read_optional_float(self, payload: dict[str, JSONValue], key: str) -> float | None:
  623. value = payload.get(key)
  624. if isinstance(value, (int, float)) and not isinstance(value, bool):
  625. return float(value)
  626. return None
  627. def _read_optional_int(self, payload: dict[str, JSONValue], key: str) -> int | None:
  628. value = payload.get(key)
  629. if isinstance(value, int) and not isinstance(value, bool):
  630. return value
  631. return None
  632. def _read_relevant_memories(
  633. self,
  634. *,
  635. agent_run: AgentRun,
  636. agent_version: AgentVersion,
  637. ) -> tuple[list[MemorySearchResultContract], dict[str, JSONValue]]:
  638. if self.memory_client is None:
  639. return [], {"memory_read_enabled": False, "memory_read_reason": "client_missing"}
  640. if not self._read_bool(agent_version.memory_policy_json, "enabled", default=True):
  641. return [], {"memory_read_enabled": False, "memory_read_reason": "policy_disabled"}
  642. query = agent_run.input_text or str(agent_run.input_json or "")
  643. if not query:
  644. return [], {"memory_read_enabled": True, "memory_read_count": 0}
  645. scope = self._resolve_memory_scope(agent_run=agent_run, agent_version=agent_version)
  646. if scope is None:
  647. return [], {
  648. "memory_read_enabled": True,
  649. "memory_read_count": 0,
  650. "memory_read_reason": "scope_unavailable",
  651. }
  652. scope_type, scope_id = scope
  653. try:
  654. results = self.memory_client.search_memories(
  655. MemorySearchRequestContract(
  656. tenant_id=agent_run.tenant_id,
  657. query=query,
  658. scope_type=scope_type,
  659. scope_id=scope_id,
  660. owner_agent_id=agent_run.agent_id,
  661. session_id=agent_run.session_id,
  662. limit=self._read_int(
  663. agent_version.memory_policy_json,
  664. "read_top_k",
  665. default=8,
  666. ),
  667. )
  668. )
  669. except MemoryClientError as exc:
  670. return [], {
  671. "memory_read_enabled": True,
  672. "memory_read_count": 0,
  673. "memory_read_error": str(exc),
  674. }
  675. return results, {
  676. "memory_read_enabled": True,
  677. "memory_read_count": len(results),
  678. "memory_scope_type": scope_type,
  679. "memory_scope_id": scope_id,
  680. }
  681. def _write_interaction_memory(
  682. self,
  683. *,
  684. agent_run: AgentRun,
  685. agent_version: AgentVersion,
  686. output_text: str,
  687. ) -> dict[str, JSONValue]:
  688. if self.memory_client is None:
  689. return {"memory_write_enabled": False, "memory_write_reason": "client_missing"}
  690. if not self._read_bool(agent_version.memory_policy_json, "write_enabled", default=True):
  691. return {"memory_write_enabled": False, "memory_write_reason": "policy_disabled"}
  692. scope = self._resolve_memory_scope(agent_run=agent_run, agent_version=agent_version)
  693. if scope is None:
  694. return {"memory_write_enabled": True, "memory_write_reason": "scope_unavailable"}
  695. scope_type, scope_id = scope
  696. try:
  697. memory = self.memory_client.create_memory(
  698. MemoryCreateContract(
  699. tenant_id=agent_run.tenant_id,
  700. scope_type=scope_type,
  701. scope_id=scope_id,
  702. memory_type="conversation",
  703. content_text=self._format_interaction_memory(
  704. agent_run=agent_run,
  705. output_text=output_text,
  706. ),
  707. content_json={
  708. "agent_run_id": agent_run.id,
  709. "agent_version_id": agent_version.id,
  710. "input_text": agent_run.input_text,
  711. "output_text": output_text,
  712. },
  713. metadata_json={
  714. "source": "agent-service",
  715. "role": agent_version.role,
  716. "version_no": agent_version.version_no,
  717. },
  718. owner_agent_id=agent_run.agent_id,
  719. session_id=agent_run.session_id,
  720. source_ref=f"agent_run:{agent_run.id}",
  721. importance_score=self._read_nested_int(
  722. agent_version.memory_policy_json,
  723. "config_json",
  724. "write_importance_score",
  725. default=50,
  726. ),
  727. )
  728. )
  729. except MemoryClientError as exc:
  730. return {
  731. "memory_write_enabled": True,
  732. "memory_write_error": str(exc),
  733. }
  734. return {
  735. "memory_write_enabled": True,
  736. "memory_written_id": memory.id,
  737. "memory_scope_type": scope_type,
  738. "memory_scope_id": scope_id,
  739. }
  740. def _resolve_memory_scope(
  741. self,
  742. *,
  743. agent_run: AgentRun,
  744. agent_version: AgentVersion,
  745. ) -> tuple[MemoryScopeType, str] | None:
  746. scope_value = self._read_optional_string(
  747. agent_version.memory_policy_json,
  748. "memory_scope",
  749. ) or "session"
  750. if scope_value == "tenant":
  751. return "tenant", agent_run.tenant_id
  752. if scope_value == "agent":
  753. return "agent", agent_run.agent_id
  754. if scope_value == "session" and agent_run.session_id:
  755. return "session", agent_run.session_id
  756. if scope_value == "user":
  757. user_id = self._read_input_json_string(agent_run=agent_run, key="user_id")
  758. if user_id is not None:
  759. return "user", user_id
  760. if scope_value == "team":
  761. team_id = self._read_input_json_string(agent_run=agent_run, key="team_id")
  762. if team_id is not None:
  763. return "team", team_id
  764. return None
  765. def _format_memory_context(self, memory_results: list[MemorySearchResultContract]) -> str:
  766. lines = ["Relevant memories:"]
  767. for index, result in enumerate(memory_results, start=1):
  768. lines.append(f"{index}. {result.item.content_text}")
  769. return "\n".join(lines)
  770. def _format_interaction_memory(self, *, agent_run: AgentRun, output_text: str) -> str:
  771. input_text = agent_run.input_text or str(agent_run.input_json or {})
  772. return f"User input: {input_text}\nAgent output: {output_text}"
  773. def _read_bool(self, payload: dict[str, JSONValue], key: str, *, default: bool) -> bool:
  774. value = payload.get(key)
  775. if isinstance(value, bool):
  776. return value
  777. return default
  778. def _read_int(self, payload: dict[str, JSONValue], key: str, *, default: int) -> int:
  779. value = payload.get(key)
  780. if isinstance(value, int) and not isinstance(value, bool):
  781. return value
  782. return default
  783. def _read_nested_int(
  784. self,
  785. payload: dict[str, JSONValue],
  786. parent_key: str,
  787. child_key: str,
  788. *,
  789. default: int,
  790. ) -> int:
  791. parent_value = payload.get(parent_key)
  792. if not isinstance(parent_value, dict):
  793. return default
  794. return self._read_int(
  795. cast(dict[str, JSONValue], parent_value),
  796. child_key,
  797. default=default,
  798. )
  799. def _read_input_json_string(self, *, agent_run: AgentRun, key: str) -> str | None:
  800. if agent_run.input_json is None:
  801. return None
  802. value = agent_run.input_json.get(key)
  803. if isinstance(value, str) and value:
  804. return value
  805. return None
  806. def build_agent_application_service(
  807. *,
  808. db: Session,
  809. settings: AgentServiceSettings,
  810. ) -> AgentApplicationService:
  811. return AgentApplicationService(
  812. agent_repository=AgentDefinitionRepository(db),
  813. agent_version_repository=AgentVersionRepository(db),
  814. agent_run_repository=AgentRunRepository(db),
  815. model_gateway_client=ModelGatewayClient(
  816. base_url=settings.model_gateway_service_url,
  817. timeout_seconds=settings.model_gateway_timeout_seconds,
  818. ),
  819. memory_client=MemoryClient(
  820. base_url=settings.memory_service_url,
  821. timeout_seconds=settings.memory_service_timeout_seconds,
  822. ),
  823. tool_client=ToolServiceClient(
  824. base_url=settings.tool_service_url,
  825. timeout_seconds=settings.tool_service_timeout_seconds,
  826. ),
  827. skill_client=SkillServiceClient(
  828. base_url=settings.skill_service_url,
  829. timeout_seconds=settings.skill_service_timeout_seconds,
  830. ),
  831. )