services.py 48 KB

12345678910111213141516171819202122232425262728293031323334353637383940414243444546474849505152535455565758596061626364656667686970717273747576777879808182838485868788899091929394959697989910010110210310410510610710810911011111211311411511611711811912012112212312412512612712812913013113213313413513613713813914014114214314414514614714814915015115215315415515615715815916016116216316416516616716816917017117217317417517617717817918018118218318418518618718818919019119219319419519619719819920020120220320420520620720820921021121221321421521621721821922022122222322422522622722822923023123223323423523623723823924024124224324424524624724824925025125225325425525625725825926026126226326426526626726826927027127227327427527627727827928028128228328428528628728828929029129229329429529629729829930030130230330430530630730830931031131231331431531631731831932032132232332432532632732832933033133233333433533633733833934034134234334434534634734834935035135235335435535635735835936036136236336436536636736836937037137237337437537637737837938038138238338438538638738838939039139239339439539639739839940040140240340440540640740840941041141241341441541641741841942042142242342442542642742842943043143243343443543643743843944044144244344444544644744844945045145245345445545645745845946046146246346446546646746846947047147247347447547647747847948048148248348448548648748848949049149249349449549649749849950050150250350450550650750850951051151251351451551651751851952052152252352452552652752852953053153253353453553653753853954054154254354454554654754854955055155255355455555655755855956056156256356456556656756856957057157257357457557657757857958058158258358458558658758858959059159259359459559659759859960060160260360460560660760860961061161261361461561661761861962062162262362462562662762862963063163263363463563663763863964064164264364464564664764864965065165265365465565665765865966066166266366466566666766866967067167267367467567667767867968068168268368468568668768868969069169269369469569669769869970070170270370470570670770870971071171271371471571671771871972072172272372472572672772872973073173273373473573673773873974074174274374474574674774874975075175275375475575675775875976076176276376476576676776876977077177277377477577677777877978078178278378478578678778878979079179279379479579679779879980080180280380480580680780880981081181281381481581681781881982082182282382482582682782882983083183283383483583683783883984084184284384484584684784884985085185285385485585685785885986086186286386486586686786886987087187287387487587687787887988088188288388488588688788888989089189289389489589689789889990090190290390490590690790890991091191291391491591691791891992092192292392492592692792892993093193293393493593693793893994094194294394494594694794894995095195295395495595695795895996096196296396496596696796896997097197297397497597697797897998098198298398498598698798898999099199299399499599699799899910001001100210031004100510061007100810091010101110121013101410151016101710181019102010211022102310241025102610271028102910301031103210331034103510361037103810391040104110421043104410451046104710481049105010511052105310541055105610571058105910601061106210631064106510661067106810691070107110721073107410751076107710781079108010811082108310841085108610871088108910901091109210931094109510961097109810991100110111021103110411051106110711081109111011111112111311141115111611171118111911201121112211231124112511261127112811291130113111321133113411351136113711381139114011411142114311441145114611471148114911501151115211531154115511561157115811591160116111621163116411651166116711681169117011711172117311741175117611771178117911801181118211831184118511861187118811891190119111921193119411951196119711981199120012011202120312041205120612071208120912101211121212131214121512161217121812191220122112221223122412251226122712281229123012311232123312341235123612371238123912401241124212431244124512461247124812491250125112521253125412551256125712581259126012611262126312641265126612671268126912701271127212731274127512761277127812791280128112821283128412851286
  1. import json
  2. from datetime import datetime, timedelta
  3. from typing import cast
  4. from sqlalchemy.orm import Session
  5. from core_events import EventPublishContract, EventServiceClient, EventServiceClientError
  6. from core_domain import (
  7. AgentSkillRefContract,
  8. AgentToolRefContract,
  9. ChatCompletionRequestContract,
  10. ChatMessageContract,
  11. MemoryCreateContract,
  12. MemoryScopeType,
  13. MemorySearchRequestContract,
  14. MemorySearchResultContract,
  15. )
  16. from core_shared import JSONValue
  17. from app.bootstrap.settings import AgentServiceSettings
  18. from app.db.models import AgentDefinition, AgentRun, AgentToolInvocation, AgentVersion
  19. from app.domain.repositories import (
  20. AgentDefinitionRepository,
  21. AgentRunRepository,
  22. AgentToolInvocationRepository,
  23. AgentVersionRepository,
  24. )
  25. from app.infrastructure.model_gateway_client import ModelGatewayClient, ModelGatewayClientError
  26. from app.infrastructure.memory_client import MemoryClient, MemoryClientError
  27. from app.infrastructure.skill_client import SkillServiceClient, SkillServiceClientError
  28. from app.infrastructure.tool_client import ToolServiceClient, ToolServiceClientError
  29. from app.schemas.agent import (
  30. AgentCreateRequest,
  31. AgentRunCreateRequest,
  32. AgentRunExecuteRequest,
  33. AgentRunStatusUpdateRequest,
  34. AgentStatusUpdateRequest,
  35. AgentVersionCreateRequest,
  36. )
  37. class AgentApplicationService:
  38. def __init__(
  39. self,
  40. *,
  41. agent_repository: AgentDefinitionRepository,
  42. agent_version_repository: AgentVersionRepository,
  43. agent_run_repository: AgentRunRepository,
  44. agent_tool_invocation_repository: AgentToolInvocationRepository,
  45. model_gateway_client: ModelGatewayClient | None = None,
  46. memory_client: MemoryClient | None = None,
  47. tool_client: ToolServiceClient | None = None,
  48. skill_client: SkillServiceClient | None = None,
  49. event_client: EventServiceClient | None = None,
  50. react_max_steps: int = 5,
  51. ) -> None:
  52. self.agent_repository = agent_repository
  53. self.agent_version_repository = agent_version_repository
  54. self.agent_run_repository = agent_run_repository
  55. self.agent_tool_invocation_repository = agent_tool_invocation_repository
  56. self.model_gateway_client = model_gateway_client
  57. self.memory_client = memory_client
  58. self.tool_client = tool_client
  59. self.skill_client = skill_client
  60. self.event_client = event_client
  61. self.react_max_steps = react_max_steps
  62. def create_agent(self, payload: AgentCreateRequest) -> AgentDefinition:
  63. return self.agent_repository.create(
  64. tenant_id=payload.tenant_id,
  65. code=payload.code,
  66. name=payload.name,
  67. description=payload.description,
  68. agent_type=payload.agent_type,
  69. owner_user_id=payload.owner_user_id,
  70. metadata_json=payload.metadata_json,
  71. )
  72. def list_agents(self, *, tenant_id: str) -> list[AgentDefinition]:
  73. return self.agent_repository.list_by_tenant(tenant_id=tenant_id)
  74. def update_agent_status(
  75. self,
  76. *,
  77. agent_id: str,
  78. payload: AgentStatusUpdateRequest,
  79. ) -> AgentDefinition | None:
  80. return self.agent_repository.update_status(
  81. tenant_id=payload.tenant_id,
  82. agent_id=agent_id,
  83. status=payload.status,
  84. )
  85. def create_agent_version(self, payload: AgentVersionCreateRequest) -> AgentVersion:
  86. agent = self.agent_repository.get_by_id(
  87. tenant_id=payload.tenant_id,
  88. agent_id=payload.agent_id,
  89. )
  90. if agent is None:
  91. raise ValueError(f"agent not found: {payload.agent_id}")
  92. return self.agent_version_repository.create(
  93. tenant_id=payload.tenant_id,
  94. agent_id=payload.agent_id,
  95. status=payload.status,
  96. role=payload.role,
  97. goal=payload.goal,
  98. system_prompt=payload.system_prompt,
  99. model_config_json=payload.model_config_data.model_dump(mode="json"),
  100. memory_policy_json=payload.memory_policy.model_dump(mode="json"),
  101. tool_refs_json=[item.model_dump(mode="json") for item in payload.tool_refs],
  102. skill_refs_json=[item.model_dump(mode="json") for item in payload.skill_refs],
  103. )
  104. def list_agent_versions(self, *, tenant_id: str, agent_id: str) -> list[AgentVersion]:
  105. return self.agent_version_repository.list_by_agent(tenant_id=tenant_id, agent_id=agent_id)
  106. def create_agent_run(self, payload: AgentRunCreateRequest) -> AgentRun:
  107. agent_version = self._resolve_agent_version(
  108. tenant_id=payload.tenant_id,
  109. agent_id=payload.agent_id,
  110. agent_version_id=payload.agent_version_id,
  111. )
  112. if agent_version is None:
  113. raise ValueError("published agent version not found")
  114. agent_run = self.agent_run_repository.create(
  115. tenant_id=payload.tenant_id,
  116. agent_id=payload.agent_id,
  117. agent_version_id=agent_version.id,
  118. session_id=payload.session_id,
  119. input_text=payload.input_text,
  120. input_json=payload.input_json,
  121. )
  122. self._publish_event(
  123. event_type="agent.run.created",
  124. agent_run=agent_run,
  125. payload_json={"agent_run_id": agent_run.id, "status": agent_run.status},
  126. )
  127. return agent_run
  128. def list_agent_runs(
  129. self,
  130. *,
  131. tenant_id: str,
  132. agent_id: str | None = None,
  133. session_id: str | None = None,
  134. ) -> list[AgentRun]:
  135. return self.agent_run_repository.list_by_scope(
  136. tenant_id=tenant_id,
  137. agent_id=agent_id,
  138. session_id=session_id,
  139. )
  140. def list_agent_tool_invocations(
  141. self,
  142. *,
  143. tenant_id: str,
  144. agent_run_id: str,
  145. ) -> list[AgentToolInvocation]:
  146. return self.agent_tool_invocation_repository.list_by_run(
  147. tenant_id=tenant_id,
  148. agent_run_id=agent_run_id,
  149. )
  150. def update_agent_run_status(
  151. self,
  152. *,
  153. agent_run_id: str,
  154. payload: AgentRunStatusUpdateRequest,
  155. ) -> AgentRun | None:
  156. entity = self.agent_run_repository.get_by_id(
  157. tenant_id=payload.tenant_id,
  158. agent_run_id=agent_run_id,
  159. )
  160. if entity is None:
  161. return None
  162. return self.agent_run_repository.update_status(
  163. agent_run_id=agent_run_id,
  164. status=payload.status,
  165. worker_key=payload.worker_key,
  166. output_text=payload.output_text,
  167. output_json=payload.output_json,
  168. error_code=payload.error_code,
  169. error_message=payload.error_message,
  170. )
  171. def execute_agent_run(
  172. self,
  173. *,
  174. agent_run_id: str,
  175. payload: AgentRunExecuteRequest,
  176. ) -> AgentRun | None:
  177. agent_run = self.agent_run_repository.get_by_id(
  178. tenant_id=payload.tenant_id,
  179. agent_run_id=agent_run_id,
  180. )
  181. if agent_run is None:
  182. return None
  183. agent_version = self.agent_version_repository.get_by_id(
  184. tenant_id=payload.tenant_id,
  185. agent_version_id=agent_run.agent_version_id,
  186. )
  187. if agent_version is None:
  188. return self.agent_run_repository.update_status(
  189. agent_run_id=agent_run.id,
  190. status="failed",
  191. worker_key=payload.worker_key,
  192. error_code="agent_version_missing",
  193. error_message=f"agent version not found: {agent_run.agent_version_id}",
  194. )
  195. self.agent_run_repository.update_status(
  196. agent_run_id=agent_run.id,
  197. status="running",
  198. worker_key=payload.worker_key,
  199. )
  200. memory_results, memory_metadata = self._read_relevant_memories(
  201. agent_run=agent_run,
  202. agent_version=agent_version,
  203. )
  204. selected_tools = self._select_tool_refs(agent_run=agent_run, agent_version=agent_version)
  205. selected_skills = self._select_skill_refs(agent_run=agent_run, agent_version=agent_version)
  206. if payload.dry_run:
  207. messages = self._build_chat_messages(
  208. agent_run=agent_run,
  209. agent_version=agent_version,
  210. memory_results=memory_results,
  211. capability_context=self._format_capability_plan(
  212. selected_tools=selected_tools,
  213. selected_skills=selected_skills,
  214. ),
  215. )
  216. completed_run = self.agent_run_repository.update_status(
  217. agent_run_id=agent_run.id,
  218. status="completed",
  219. worker_key=payload.worker_key,
  220. output_text=self._build_dry_run_output(
  221. agent_run=agent_run,
  222. agent_version=agent_version,
  223. ),
  224. output_json={
  225. "dry_run": True,
  226. "agent_version_id": agent_version.id,
  227. "message_count": len(messages),
  228. "messages": [message.model_dump(mode="json") for message in messages],
  229. "selected_tool_refs": [
  230. tool_ref.model_dump(mode="json") for tool_ref in selected_tools
  231. ],
  232. "selected_skill_refs": [
  233. skill_ref.model_dump(mode="json") for skill_ref in selected_skills
  234. ],
  235. **memory_metadata,
  236. },
  237. )
  238. if completed_run is not None:
  239. self._publish_event(
  240. event_type="agent.run.completed",
  241. agent_run=completed_run,
  242. payload_json={
  243. "agent_run_id": completed_run.id,
  244. "dry_run": True,
  245. "status": completed_run.status,
  246. },
  247. )
  248. return completed_run
  249. if self._read_bool(agent_version.model_config_json, "react_enabled", default=False):
  250. return self._execute_react_agent_run(
  251. agent_run=agent_run,
  252. agent_version=agent_version,
  253. payload=payload,
  254. memory_results=memory_results,
  255. memory_metadata=memory_metadata,
  256. selected_tools=selected_tools,
  257. selected_skills=selected_skills,
  258. )
  259. tool_invocations = self._invoke_selected_tools(
  260. agent_run=agent_run,
  261. agent_version=agent_version,
  262. selected_tools=selected_tools,
  263. )
  264. skill_invocations = self._invoke_selected_skills(
  265. agent_run=agent_run,
  266. selected_skills=selected_skills,
  267. worker_key=payload.worker_key,
  268. )
  269. messages = self._build_chat_messages(
  270. agent_run=agent_run,
  271. agent_version=agent_version,
  272. memory_results=memory_results,
  273. capability_context=self._format_capability_results(
  274. tool_invocations=tool_invocations,
  275. skill_invocations=skill_invocations,
  276. ),
  277. )
  278. if self.model_gateway_client is None:
  279. return self.agent_run_repository.update_status(
  280. agent_run_id=agent_run.id,
  281. status="failed",
  282. worker_key=payload.worker_key,
  283. error_code="model_gateway_missing",
  284. error_message="model gateway client is not configured",
  285. output_json={
  286. "tool_invocations": tool_invocations,
  287. "skill_invocations": skill_invocations,
  288. **memory_metadata,
  289. },
  290. )
  291. try:
  292. response = self.model_gateway_client.create_chat_completion(
  293. ChatCompletionRequestContract(
  294. model=self._read_optional_string(agent_version.model_config_json, "model"),
  295. temperature=self._read_optional_float(
  296. agent_version.model_config_json,
  297. "temperature",
  298. ),
  299. max_tokens=self._read_optional_int(
  300. agent_version.model_config_json,
  301. "max_tokens",
  302. ),
  303. messages=messages,
  304. metadata_json={
  305. "tenant_id": agent_run.tenant_id,
  306. "agent_id": agent_run.agent_id,
  307. "agent_version_id": agent_version.id,
  308. "agent_run_id": agent_run.id,
  309. },
  310. )
  311. )
  312. except ModelGatewayClientError as exc:
  313. return self.agent_run_repository.update_status(
  314. agent_run_id=agent_run.id,
  315. status="failed",
  316. worker_key=payload.worker_key,
  317. error_code="model_gateway_error",
  318. error_message=str(exc),
  319. )
  320. memory_write_metadata = self._write_interaction_memory(
  321. agent_run=agent_run,
  322. agent_version=agent_version,
  323. output_text=response.content,
  324. )
  325. completed_run = self.agent_run_repository.update_status(
  326. agent_run_id=agent_run.id,
  327. status="completed",
  328. worker_key=payload.worker_key,
  329. output_text=response.content,
  330. output_json={
  331. "dry_run": False,
  332. "agent_version_id": agent_version.id,
  333. "model": response.model,
  334. "finish_reason": response.finish_reason,
  335. "usage_json": response.usage_json,
  336. "raw_response_json": response.raw_response_json,
  337. "tool_invocations": tool_invocations,
  338. "skill_invocations": skill_invocations,
  339. **memory_metadata,
  340. **memory_write_metadata,
  341. },
  342. )
  343. if completed_run is not None:
  344. self._publish_event(
  345. event_type="agent.run.completed",
  346. agent_run=completed_run,
  347. payload_json={
  348. "agent_run_id": completed_run.id,
  349. "dry_run": False,
  350. "status": completed_run.status,
  351. },
  352. )
  353. return completed_run
  354. def _publish_event(
  355. self,
  356. *,
  357. event_type: str,
  358. agent_run: AgentRun,
  359. payload_json: dict[str, JSONValue],
  360. ) -> None:
  361. if self.event_client is None:
  362. return
  363. try:
  364. self.event_client.publish_event(
  365. EventPublishContract(
  366. tenant_id=agent_run.tenant_id,
  367. event_type=event_type,
  368. source_service="agent-service",
  369. aggregate_type="agent_run",
  370. aggregate_id=agent_run.id,
  371. correlation_id=agent_run.session_id,
  372. payload_json={
  373. **payload_json,
  374. "agent_id": agent_run.agent_id,
  375. "agent_version_id": agent_run.agent_version_id,
  376. },
  377. )
  378. )
  379. except EventServiceClientError:
  380. return
  381. def _execute_react_agent_run(
  382. self,
  383. *,
  384. agent_run: AgentRun,
  385. agent_version: AgentVersion,
  386. payload: AgentRunExecuteRequest,
  387. memory_results: list[MemorySearchResultContract],
  388. memory_metadata: dict[str, JSONValue],
  389. selected_tools: list[AgentToolRefContract],
  390. selected_skills: list[AgentSkillRefContract],
  391. ) -> AgentRun | None:
  392. if self.model_gateway_client is None:
  393. return self.agent_run_repository.update_status(
  394. agent_run_id=agent_run.id,
  395. status="failed",
  396. worker_key=payload.worker_key,
  397. error_code="model_gateway_missing",
  398. error_message="model gateway client is not configured",
  399. )
  400. skill_invocations = self._invoke_selected_skills(
  401. agent_run=agent_run,
  402. selected_skills=selected_skills,
  403. worker_key=payload.worker_key,
  404. )
  405. messages = self._build_chat_messages(
  406. agent_run=agent_run,
  407. agent_version=agent_version,
  408. memory_results=memory_results,
  409. capability_context=self._format_react_instruction(
  410. selected_tools=selected_tools,
  411. skill_invocations=skill_invocations,
  412. ),
  413. )
  414. react_steps: list[dict[str, JSONValue]] = []
  415. tool_invocations: list[dict[str, JSONValue]] = []
  416. final_answer: str | None = None
  417. max_steps = self._read_int(
  418. agent_version.model_config_json,
  419. "react_max_steps",
  420. default=self.react_max_steps,
  421. )
  422. for step_index in range(max(max_steps, 1)):
  423. try:
  424. response = self.model_gateway_client.create_chat_completion(
  425. self._build_chat_completion_request(
  426. agent_run=agent_run,
  427. agent_version=agent_version,
  428. messages=messages,
  429. )
  430. )
  431. except ModelGatewayClientError as exc:
  432. return self.agent_run_repository.update_status(
  433. agent_run_id=agent_run.id,
  434. status="failed",
  435. worker_key=payload.worker_key,
  436. error_code="model_gateway_error",
  437. error_message=str(exc),
  438. output_json={
  439. "react_steps": react_steps,
  440. "tool_invocations": tool_invocations,
  441. "skill_invocations": skill_invocations,
  442. **memory_metadata,
  443. },
  444. )
  445. action = self._parse_react_action(response.content)
  446. react_step: dict[str, JSONValue] = {
  447. "step_index": step_index,
  448. "model_content": response.content,
  449. "action": action,
  450. }
  451. react_steps.append(react_step)
  452. if action.get("action") == "finish":
  453. answer_value = action.get("answer")
  454. final_answer = answer_value if isinstance(answer_value, str) else response.content
  455. break
  456. if action.get("action") != "tool":
  457. final_answer = response.content
  458. break
  459. tool_code = action.get("tool_code")
  460. matching_tools = [
  461. item for item in selected_tools if item.tool_code == tool_code
  462. ]
  463. if not matching_tools:
  464. observation = f"tool not available: {tool_code}"
  465. react_step["observation"] = observation
  466. messages.append(ChatMessageContract(role="assistant", content=response.content))
  467. messages.append(ChatMessageContract(role="user", content=observation))
  468. continue
  469. tool_input = action.get("input_json")
  470. original_input_json = agent_run.input_json
  471. if isinstance(tool_input, dict):
  472. agent_run.input_json = {
  473. str(item_key): item_value for item_key, item_value in tool_input.items()
  474. }
  475. current_invocations = self._invoke_selected_tools(
  476. agent_run=agent_run,
  477. agent_version=agent_version,
  478. selected_tools=matching_tools[:1],
  479. )
  480. agent_run.input_json = original_input_json
  481. tool_invocations.extend(current_invocations)
  482. observation = self._format_react_observation(current_invocations)
  483. react_step["observation"] = observation
  484. messages.append(ChatMessageContract(role="assistant", content=response.content))
  485. messages.append(ChatMessageContract(role="user", content=observation))
  486. if final_answer is None:
  487. final_answer = "ReAct loop reached max steps without a final answer."
  488. memory_write_metadata = self._write_interaction_memory(
  489. agent_run=agent_run,
  490. agent_version=agent_version,
  491. output_text=final_answer,
  492. )
  493. completed_run = self.agent_run_repository.update_status(
  494. agent_run_id=agent_run.id,
  495. status="completed",
  496. worker_key=payload.worker_key,
  497. output_text=final_answer,
  498. output_json={
  499. "dry_run": False,
  500. "agent_version_id": agent_version.id,
  501. "react_enabled": True,
  502. "react_steps": react_steps,
  503. "tool_invocations": tool_invocations,
  504. "skill_invocations": skill_invocations,
  505. **memory_metadata,
  506. **memory_write_metadata,
  507. },
  508. )
  509. if completed_run is not None:
  510. self._publish_event(
  511. event_type="agent.run.completed",
  512. agent_run=completed_run,
  513. payload_json={
  514. "agent_run_id": completed_run.id,
  515. "react_enabled": True,
  516. "status": completed_run.status,
  517. },
  518. )
  519. return completed_run
  520. def execute_next_claimed_agent_run(
  521. self,
  522. *,
  523. worker_key: str,
  524. lease_seconds: int,
  525. dry_run: bool,
  526. redis_client: object | None = None,
  527. ) -> tuple[AgentRun, int] | None:
  528. released_lease_count = self.agent_run_repository.release_expired_leases(
  529. now_time=datetime.utcnow(),
  530. )
  531. claimed_agent_run = self.agent_run_repository.claim_next_queued(
  532. worker_key=worker_key,
  533. lease_expire_time=datetime.utcnow() + timedelta(seconds=lease_seconds),
  534. )
  535. if claimed_agent_run is None:
  536. return None
  537. if redis_client is not None:
  538. from core_shared.redis_primitives import DistributedLock, IdempotencyStore
  539. lock = DistributedLock(
  540. client=redis_client,
  541. name=f"agent-run:{claimed_agent_run.id}:lock",
  542. ttl_seconds=lease_seconds,
  543. )
  544. if not lock.acquire():
  545. return None
  546. idempotency_store = IdempotencyStore(
  547. client=redis_client,
  548. prefix="agent-run-idempotency",
  549. )
  550. if not idempotency_store.begin(key=claimed_agent_run.id):
  551. lock.release()
  552. return None
  553. else:
  554. lock = None
  555. idempotency_store = None
  556. try:
  557. result = self.execute_agent_run(
  558. agent_run_id=claimed_agent_run.id,
  559. payload=AgentRunExecuteRequest(
  560. tenant_id=claimed_agent_run.tenant_id,
  561. worker_key=worker_key,
  562. dry_run=dry_run,
  563. ),
  564. )
  565. if idempotency_store is not None and result is not None:
  566. idempotency_store.complete(
  567. key=claimed_agent_run.id,
  568. result={"status": result.status, "agent_run_id": result.id},
  569. )
  570. finally:
  571. if lock is not None:
  572. lock.release()
  573. if result is None:
  574. return None
  575. return result, released_lease_count
  576. def _resolve_agent_version(
  577. self,
  578. *,
  579. tenant_id: str,
  580. agent_id: str,
  581. agent_version_id: str | None,
  582. ) -> AgentVersion | None:
  583. if agent_version_id is not None:
  584. return self.agent_version_repository.get_by_id(
  585. tenant_id=tenant_id,
  586. agent_version_id=agent_version_id,
  587. )
  588. return self.agent_version_repository.get_latest_published(
  589. tenant_id=tenant_id,
  590. agent_id=agent_id,
  591. )
  592. def _build_chat_messages(
  593. self,
  594. *,
  595. agent_run: AgentRun,
  596. agent_version: AgentVersion,
  597. memory_results: list[MemorySearchResultContract] | None = None,
  598. capability_context: str | None = None,
  599. ) -> list[ChatMessageContract]:
  600. messages = [
  601. ChatMessageContract(role="system", content=agent_version.system_prompt),
  602. ]
  603. if agent_version.goal:
  604. messages.append(
  605. ChatMessageContract(role="system", content=f"Goal: {agent_version.goal}")
  606. )
  607. if memory_results:
  608. messages.append(
  609. ChatMessageContract(
  610. role="system",
  611. content=self._format_memory_context(memory_results),
  612. )
  613. )
  614. if capability_context:
  615. messages.append(ChatMessageContract(role="system", content=capability_context))
  616. if agent_run.input_text:
  617. messages.append(ChatMessageContract(role="user", content=agent_run.input_text))
  618. if agent_run.input_json:
  619. messages.append(
  620. ChatMessageContract(
  621. role="user",
  622. content=f"Structured input: {agent_run.input_json}",
  623. )
  624. )
  625. return messages
  626. def _select_tool_refs(
  627. self,
  628. *,
  629. agent_run: AgentRun,
  630. agent_version: AgentVersion,
  631. ) -> list[AgentToolRefContract]:
  632. input_preview = self._build_input_preview(agent_run)
  633. selected: list[AgentToolRefContract] = []
  634. for item in agent_version.tool_refs_json:
  635. ref = AgentToolRefContract.model_validate(item)
  636. if (
  637. ref.required
  638. or self._read_bool(ref.config_json, "auto_invoke", default=False)
  639. or self._matches_selection_keywords(ref.config_json, input_preview)
  640. ):
  641. selected.append(ref)
  642. return selected
  643. def _select_skill_refs(
  644. self,
  645. *,
  646. agent_run: AgentRun,
  647. agent_version: AgentVersion,
  648. ) -> list[AgentSkillRefContract]:
  649. input_preview = self._build_input_preview(agent_run)
  650. selected: list[AgentSkillRefContract] = []
  651. for item in agent_version.skill_refs_json:
  652. ref = AgentSkillRefContract.model_validate(item)
  653. auto_invoke = self._read_bool(ref.config_json, "auto_invoke", default=True)
  654. if auto_invoke or self._matches_selection_keywords(ref.config_json, input_preview):
  655. selected.append(ref)
  656. return selected
  657. def _invoke_selected_tools(
  658. self,
  659. *,
  660. agent_run: AgentRun,
  661. agent_version: AgentVersion,
  662. selected_tools: list[AgentToolRefContract],
  663. ) -> list[dict[str, JSONValue]]:
  664. invocations: list[dict[str, JSONValue]] = []
  665. for ref in selected_tools:
  666. invocation = self.agent_tool_invocation_repository.create(
  667. tenant_id=agent_run.tenant_id,
  668. agent_run_id=agent_run.id,
  669. agent_id=agent_run.agent_id,
  670. agent_version_id=agent_version.id,
  671. tool_code=ref.tool_code,
  672. tool_binding_id=ref.tool_binding_id,
  673. status="selected",
  674. input_json=agent_run.input_json or {},
  675. )
  676. if ref.tool_binding_id is None:
  677. self.agent_tool_invocation_repository.update_status(
  678. invocation_id=invocation.id,
  679. status="skipped",
  680. reason="tool_binding_id_missing",
  681. )
  682. invocations.append(
  683. {
  684. "status": "skipped",
  685. "reason": "tool_binding_id_missing",
  686. "tool_code": ref.tool_code,
  687. }
  688. )
  689. continue
  690. if self.tool_client is None:
  691. self.agent_tool_invocation_repository.update_status(
  692. invocation_id=invocation.id,
  693. status="failed",
  694. reason="tool_client_missing",
  695. error_message="tool client is not configured",
  696. )
  697. invocations.append(
  698. {
  699. "status": "failed",
  700. "reason": "tool_client_missing",
  701. "tool_binding_id": ref.tool_binding_id,
  702. }
  703. )
  704. continue
  705. try:
  706. self.agent_tool_invocation_repository.update_status(
  707. invocation_id=invocation.id,
  708. status="running",
  709. )
  710. detail = self.tool_client.get_tool_binding_detail(
  711. tenant_id=agent_run.tenant_id,
  712. binding_id=ref.tool_binding_id,
  713. )
  714. if not detail.binding.enabled:
  715. self.agent_tool_invocation_repository.update_status(
  716. invocation_id=invocation.id,
  717. status="failed",
  718. reason="tool_binding_disabled",
  719. error_message="tool binding is disabled",
  720. )
  721. invocations.append(
  722. {
  723. "status": "failed",
  724. "reason": "tool_binding_disabled",
  725. "tool_binding_id": ref.tool_binding_id,
  726. }
  727. )
  728. continue
  729. if detail.tool_definition.tool_type != "http":
  730. self.agent_tool_invocation_repository.update_status(
  731. invocation_id=invocation.id,
  732. status="skipped",
  733. reason="unsupported_tool_type",
  734. error_message=detail.tool_definition.tool_type,
  735. )
  736. invocations.append(
  737. {
  738. "status": "skipped",
  739. "reason": "unsupported_tool_type",
  740. "tool_type": detail.tool_definition.tool_type,
  741. "tool_binding_id": ref.tool_binding_id,
  742. }
  743. )
  744. continue
  745. output_text, output_json = self.tool_client.invoke_http_tool(
  746. detail=detail,
  747. input_json=agent_run.input_json or {},
  748. config_json=ref.config_json,
  749. )
  750. except ToolServiceClientError as exc:
  751. self.agent_tool_invocation_repository.update_status(
  752. invocation_id=invocation.id,
  753. status="failed",
  754. reason="tool_service_error",
  755. error_message=str(exc),
  756. )
  757. invocations.append(
  758. {
  759. "status": "failed",
  760. "reason": str(exc),
  761. "tool_binding_id": ref.tool_binding_id,
  762. }
  763. )
  764. continue
  765. self.agent_tool_invocation_repository.update_status(
  766. invocation_id=invocation.id,
  767. status="completed",
  768. output_text=output_text,
  769. output_json=output_json,
  770. )
  771. invocations.append(
  772. {
  773. "status": "completed",
  774. "tool_binding_id": ref.tool_binding_id,
  775. "tool_code": detail.tool_definition.code,
  776. "output_text": output_text,
  777. "output_json": output_json,
  778. }
  779. )
  780. return invocations
  781. def _invoke_selected_skills(
  782. self,
  783. *,
  784. agent_run: AgentRun,
  785. selected_skills: list[AgentSkillRefContract],
  786. worker_key: str | None,
  787. ) -> list[dict[str, JSONValue]]:
  788. invocations: list[dict[str, JSONValue]] = []
  789. for ref in selected_skills:
  790. if self.skill_client is None:
  791. invocations.append(
  792. {
  793. "status": "failed",
  794. "reason": "skill_client_missing",
  795. "skill_id": ref.skill_id,
  796. "skill_code": ref.skill_code,
  797. }
  798. )
  799. continue
  800. skill_id = ref.skill_id or self._resolve_skill_id_by_code(
  801. tenant_id=agent_run.tenant_id,
  802. skill_code=ref.skill_code,
  803. )
  804. if skill_id is None:
  805. invocations.append(
  806. {
  807. "status": "failed",
  808. "reason": "skill_id_missing",
  809. "skill_code": ref.skill_code,
  810. }
  811. )
  812. continue
  813. try:
  814. created_run = self.skill_client.create_skill_run(
  815. tenant_id=agent_run.tenant_id,
  816. skill_id=skill_id,
  817. skill_version_id=self._read_optional_string(
  818. ref.config_json,
  819. "skill_version_id",
  820. ),
  821. installation_id=self._read_optional_string(
  822. ref.config_json,
  823. "installation_id",
  824. ),
  825. input_json=self._build_skill_input_json(agent_run=agent_run, ref=ref),
  826. )
  827. executed_run = self.skill_client.execute_skill_run(
  828. tenant_id=agent_run.tenant_id,
  829. skill_run_id=created_run.id,
  830. worker_key=worker_key,
  831. )
  832. except SkillServiceClientError as exc:
  833. invocations.append(
  834. {
  835. "status": "failed",
  836. "reason": str(exc),
  837. "skill_id": skill_id,
  838. "skill_code": ref.skill_code,
  839. }
  840. )
  841. continue
  842. invocations.append(
  843. {
  844. "status": executed_run.status,
  845. "skill_id": skill_id,
  846. "skill_code": ref.skill_code,
  847. "skill_run_id": executed_run.id,
  848. "output_text": executed_run.output_text,
  849. "output_json": executed_run.output_json,
  850. "error_code": executed_run.error_code,
  851. "error_message": executed_run.error_message,
  852. }
  853. )
  854. return invocations
  855. def _format_capability_plan(
  856. self,
  857. *,
  858. selected_tools: list[AgentToolRefContract],
  859. selected_skills: list[AgentSkillRefContract],
  860. ) -> str:
  861. return (
  862. "Selected capability plan before model call:\n"
  863. f"Tools: {[item.model_dump(mode='json') for item in selected_tools]}\n"
  864. f"Skills: {[item.model_dump(mode='json') for item in selected_skills]}"
  865. )
  866. def _format_capability_results(
  867. self,
  868. *,
  869. tool_invocations: list[dict[str, JSONValue]],
  870. skill_invocations: list[dict[str, JSONValue]],
  871. ) -> str | None:
  872. if not tool_invocations and not skill_invocations:
  873. return None
  874. return (
  875. "Capability invocation results before model call:\n"
  876. f"Tools: {tool_invocations}\n"
  877. f"Skills: {skill_invocations}"
  878. )
  879. def _format_react_instruction(
  880. self,
  881. *,
  882. selected_tools: list[AgentToolRefContract],
  883. skill_invocations: list[dict[str, JSONValue]],
  884. ) -> str:
  885. return (
  886. "Use ReAct JSON only. Respond with one JSON object per turn.\n"
  887. "To call a tool: "
  888. '{"action":"tool","tool_code":"code","input_json":{...}}\n'
  889. "To finish: "
  890. '{"action":"finish","answer":"final answer"}\n'
  891. f"Available tools: {[item.model_dump(mode='json') for item in selected_tools]}\n"
  892. f"Pre-run skill results: {skill_invocations}"
  893. )
  894. def _format_react_observation(
  895. self,
  896. tool_invocations: list[dict[str, JSONValue]],
  897. ) -> str:
  898. return f"Observation: {tool_invocations}"
  899. def _parse_react_action(self, content: str) -> dict[str, JSONValue]:
  900. try:
  901. value = json.loads(content)
  902. except json.JSONDecodeError:
  903. start_index = content.find("{")
  904. end_index = content.rfind("}")
  905. if start_index < 0 or end_index <= start_index:
  906. return {"action": "finish", "answer": content}
  907. try:
  908. value = json.loads(content[start_index : end_index + 1])
  909. except json.JSONDecodeError:
  910. return {"action": "finish", "answer": content}
  911. if not isinstance(value, dict):
  912. return {"action": "finish", "answer": content}
  913. return {str(item_key): item_value for item_key, item_value in value.items()}
  914. def _build_chat_completion_request(
  915. self,
  916. *,
  917. agent_run: AgentRun,
  918. agent_version: AgentVersion,
  919. messages: list[ChatMessageContract],
  920. ) -> ChatCompletionRequestContract:
  921. return ChatCompletionRequestContract(
  922. model=self._read_optional_string(agent_version.model_config_json, "model"),
  923. temperature=self._read_optional_float(
  924. agent_version.model_config_json,
  925. "temperature",
  926. ),
  927. max_tokens=self._read_optional_int(
  928. agent_version.model_config_json,
  929. "max_tokens",
  930. ),
  931. messages=messages,
  932. metadata_json={
  933. "tenant_id": agent_run.tenant_id,
  934. "agent_id": agent_run.agent_id,
  935. "agent_version_id": agent_version.id,
  936. "agent_run_id": agent_run.id,
  937. },
  938. )
  939. def _build_skill_input_json(
  940. self,
  941. *,
  942. agent_run: AgentRun,
  943. ref: AgentSkillRefContract,
  944. ) -> dict[str, JSONValue]:
  945. input_json: dict[str, JSONValue] = dict(agent_run.input_json or {})
  946. if agent_run.input_text:
  947. input_json.setdefault("input_text", agent_run.input_text)
  948. configured_input = ref.config_json.get("input_json")
  949. if isinstance(configured_input, dict):
  950. input_json.update(
  951. {str(item_key): item_value for item_key, item_value in configured_input.items()}
  952. )
  953. return input_json
  954. def _resolve_skill_id_by_code(self, *, tenant_id: str, skill_code: str | None) -> str | None:
  955. if skill_code is None or self.skill_client is None:
  956. return None
  957. try:
  958. skills = self.skill_client.list_skills(tenant_id=tenant_id)
  959. except SkillServiceClientError:
  960. return None
  961. for skill in skills:
  962. if skill.code == skill_code:
  963. return skill.id
  964. return None
  965. def _build_input_preview(self, agent_run: AgentRun) -> str:
  966. return f"{agent_run.input_text or ''} {agent_run.input_json or {}}".lower()
  967. def _matches_selection_keywords(
  968. self,
  969. config_json: dict[str, JSONValue],
  970. input_preview: str,
  971. ) -> bool:
  972. keywords = config_json.get("selection_keywords")
  973. if not isinstance(keywords, list):
  974. return False
  975. return any(
  976. isinstance(keyword, str) and keyword.lower() in input_preview
  977. for keyword in keywords
  978. )
  979. def _build_dry_run_output(self, *, agent_run: AgentRun, agent_version: AgentVersion) -> str:
  980. input_preview = agent_run.input_text or str(agent_run.input_json or {})
  981. return (
  982. f"[dry-run] Agent role={agent_version.role} "
  983. f"version={agent_version.version_no} received: {input_preview}"
  984. )
  985. def _read_optional_string(self, payload: dict[str, JSONValue], key: str) -> str | None:
  986. value = payload.get(key)
  987. if isinstance(value, str) and value:
  988. return value
  989. return None
  990. def _read_optional_float(self, payload: dict[str, JSONValue], key: str) -> float | None:
  991. value = payload.get(key)
  992. if isinstance(value, (int, float)) and not isinstance(value, bool):
  993. return float(value)
  994. return None
  995. def _read_optional_int(self, payload: dict[str, JSONValue], key: str) -> int | None:
  996. value = payload.get(key)
  997. if isinstance(value, int) and not isinstance(value, bool):
  998. return value
  999. return None
  1000. def _read_relevant_memories(
  1001. self,
  1002. *,
  1003. agent_run: AgentRun,
  1004. agent_version: AgentVersion,
  1005. ) -> tuple[list[MemorySearchResultContract], dict[str, JSONValue]]:
  1006. if self.memory_client is None:
  1007. return [], {"memory_read_enabled": False, "memory_read_reason": "client_missing"}
  1008. if not self._read_bool(agent_version.memory_policy_json, "enabled", default=True):
  1009. return [], {"memory_read_enabled": False, "memory_read_reason": "policy_disabled"}
  1010. query = agent_run.input_text or str(agent_run.input_json or "")
  1011. if not query:
  1012. return [], {"memory_read_enabled": True, "memory_read_count": 0}
  1013. scope = self._resolve_memory_scope(agent_run=agent_run, agent_version=agent_version)
  1014. if scope is None:
  1015. return [], {
  1016. "memory_read_enabled": True,
  1017. "memory_read_count": 0,
  1018. "memory_read_reason": "scope_unavailable",
  1019. }
  1020. scope_type, scope_id = scope
  1021. try:
  1022. results = self.memory_client.search_memories(
  1023. MemorySearchRequestContract(
  1024. tenant_id=agent_run.tenant_id,
  1025. query=query,
  1026. scope_type=scope_type,
  1027. scope_id=scope_id,
  1028. owner_agent_id=agent_run.agent_id,
  1029. session_id=agent_run.session_id,
  1030. limit=self._read_int(
  1031. agent_version.memory_policy_json,
  1032. "read_top_k",
  1033. default=8,
  1034. ),
  1035. )
  1036. )
  1037. except MemoryClientError as exc:
  1038. return [], {
  1039. "memory_read_enabled": True,
  1040. "memory_read_count": 0,
  1041. "memory_read_error": str(exc),
  1042. }
  1043. return results, {
  1044. "memory_read_enabled": True,
  1045. "memory_read_count": len(results),
  1046. "memory_scope_type": scope_type,
  1047. "memory_scope_id": scope_id,
  1048. }
  1049. def _write_interaction_memory(
  1050. self,
  1051. *,
  1052. agent_run: AgentRun,
  1053. agent_version: AgentVersion,
  1054. output_text: str,
  1055. ) -> dict[str, JSONValue]:
  1056. if self.memory_client is None:
  1057. return {"memory_write_enabled": False, "memory_write_reason": "client_missing"}
  1058. if not self._read_bool(agent_version.memory_policy_json, "write_enabled", default=True):
  1059. return {"memory_write_enabled": False, "memory_write_reason": "policy_disabled"}
  1060. scope = self._resolve_memory_scope(agent_run=agent_run, agent_version=agent_version)
  1061. if scope is None:
  1062. return {"memory_write_enabled": True, "memory_write_reason": "scope_unavailable"}
  1063. scope_type, scope_id = scope
  1064. try:
  1065. memory = self.memory_client.create_memory(
  1066. MemoryCreateContract(
  1067. tenant_id=agent_run.tenant_id,
  1068. scope_type=scope_type,
  1069. scope_id=scope_id,
  1070. memory_type="conversation",
  1071. content_text=self._format_interaction_memory(
  1072. agent_run=agent_run,
  1073. output_text=output_text,
  1074. ),
  1075. content_json={
  1076. "agent_run_id": agent_run.id,
  1077. "agent_version_id": agent_version.id,
  1078. "input_text": agent_run.input_text,
  1079. "output_text": output_text,
  1080. },
  1081. metadata_json={
  1082. "source": "agent-service",
  1083. "role": agent_version.role,
  1084. "version_no": agent_version.version_no,
  1085. },
  1086. owner_agent_id=agent_run.agent_id,
  1087. session_id=agent_run.session_id,
  1088. source_ref=f"agent_run:{agent_run.id}",
  1089. importance_score=self._read_nested_int(
  1090. agent_version.memory_policy_json,
  1091. "config_json",
  1092. "write_importance_score",
  1093. default=50,
  1094. ),
  1095. )
  1096. )
  1097. except MemoryClientError as exc:
  1098. return {
  1099. "memory_write_enabled": True,
  1100. "memory_write_error": str(exc),
  1101. }
  1102. return {
  1103. "memory_write_enabled": True,
  1104. "memory_written_id": memory.id,
  1105. "memory_scope_type": scope_type,
  1106. "memory_scope_id": scope_id,
  1107. }
  1108. def _resolve_memory_scope(
  1109. self,
  1110. *,
  1111. agent_run: AgentRun,
  1112. agent_version: AgentVersion,
  1113. ) -> tuple[MemoryScopeType, str] | None:
  1114. scope_value = self._read_optional_string(
  1115. agent_version.memory_policy_json,
  1116. "memory_scope",
  1117. ) or "session"
  1118. if scope_value == "tenant":
  1119. return "tenant", agent_run.tenant_id
  1120. if scope_value == "agent":
  1121. return "agent", agent_run.agent_id
  1122. if scope_value == "session" and agent_run.session_id:
  1123. return "session", agent_run.session_id
  1124. if scope_value == "user":
  1125. user_id = self._read_input_json_string(agent_run=agent_run, key="user_id")
  1126. if user_id is not None:
  1127. return "user", user_id
  1128. if scope_value == "team":
  1129. team_id = self._read_input_json_string(agent_run=agent_run, key="team_id")
  1130. if team_id is not None:
  1131. return "team", team_id
  1132. return None
  1133. def _format_memory_context(self, memory_results: list[MemorySearchResultContract]) -> str:
  1134. lines = ["Relevant memories:"]
  1135. for index, result in enumerate(memory_results, start=1):
  1136. lines.append(f"{index}. {result.item.content_text}")
  1137. return "\n".join(lines)
  1138. def _format_interaction_memory(self, *, agent_run: AgentRun, output_text: str) -> str:
  1139. input_text = agent_run.input_text or str(agent_run.input_json or {})
  1140. return f"User input: {input_text}\nAgent output: {output_text}"
  1141. def _read_bool(self, payload: dict[str, JSONValue], key: str, *, default: bool) -> bool:
  1142. value = payload.get(key)
  1143. if isinstance(value, bool):
  1144. return value
  1145. return default
  1146. def _read_int(self, payload: dict[str, JSONValue], key: str, *, default: int) -> int:
  1147. value = payload.get(key)
  1148. if isinstance(value, int) and not isinstance(value, bool):
  1149. return value
  1150. return default
  1151. def _read_nested_int(
  1152. self,
  1153. payload: dict[str, JSONValue],
  1154. parent_key: str,
  1155. child_key: str,
  1156. *,
  1157. default: int,
  1158. ) -> int:
  1159. parent_value = payload.get(parent_key)
  1160. if not isinstance(parent_value, dict):
  1161. return default
  1162. return self._read_int(
  1163. cast(dict[str, JSONValue], parent_value),
  1164. child_key,
  1165. default=default,
  1166. )
  1167. def _read_input_json_string(self, *, agent_run: AgentRun, key: str) -> str | None:
  1168. if agent_run.input_json is None:
  1169. return None
  1170. value = agent_run.input_json.get(key)
  1171. if isinstance(value, str) and value:
  1172. return value
  1173. return None
  1174. def build_agent_application_service(
  1175. *,
  1176. db: Session,
  1177. settings: AgentServiceSettings,
  1178. ) -> AgentApplicationService:
  1179. return AgentApplicationService(
  1180. agent_repository=AgentDefinitionRepository(db),
  1181. agent_version_repository=AgentVersionRepository(db),
  1182. agent_run_repository=AgentRunRepository(db),
  1183. agent_tool_invocation_repository=AgentToolInvocationRepository(db),
  1184. model_gateway_client=ModelGatewayClient(
  1185. base_url=settings.model_gateway_service_url,
  1186. timeout_seconds=settings.model_gateway_timeout_seconds,
  1187. ),
  1188. memory_client=MemoryClient(
  1189. base_url=settings.memory_service_url,
  1190. timeout_seconds=settings.memory_service_timeout_seconds,
  1191. ),
  1192. tool_client=ToolServiceClient(
  1193. base_url=settings.tool_service_url,
  1194. timeout_seconds=settings.tool_service_timeout_seconds,
  1195. ),
  1196. skill_client=SkillServiceClient(
  1197. base_url=settings.skill_service_url,
  1198. timeout_seconds=settings.skill_service_timeout_seconds,
  1199. ),
  1200. event_client=EventServiceClient(
  1201. base_url=settings.event_service_url,
  1202. timeout_seconds=settings.event_service_timeout_seconds,
  1203. ),
  1204. react_max_steps=settings.react_max_steps,
  1205. )