intermesh 0.3.0__tar.gz → 0.4.1__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (42) hide show
  1. {intermesh-0.3.0 → intermesh-0.4.1}/PKG-INFO +3 -1
  2. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/__init__.py +1 -1
  3. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/admin.py +98 -0
  4. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/agent.py +66 -8
  5. intermesh-0.4.1/intermesh/approval.py +186 -0
  6. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/hub.py +617 -46
  7. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/message.py +8 -0
  8. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/peering.py +47 -3
  9. intermesh-0.4.1/intermesh/store.py +489 -0
  10. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/task.py +14 -1
  11. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh.egg-info/PKG-INFO +3 -1
  12. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh.egg-info/SOURCES.txt +1 -0
  13. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh.egg-info/requires.txt +3 -0
  14. {intermesh-0.3.0 → intermesh-0.4.1}/pyproject.toml +6 -1
  15. intermesh-0.3.0/intermesh/store.py +0 -177
  16. {intermesh-0.3.0 → intermesh-0.4.1}/README.md +0 -0
  17. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/adapters.py +0 -0
  18. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/apikeys.py +0 -0
  19. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/audit.py +0 -0
  20. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/bridge.py +0 -0
  21. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/cli.py +0 -0
  22. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/config.py +0 -0
  23. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/crypto.py +0 -0
  24. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/egress.py +0 -0
  25. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/escrow.py +0 -0
  26. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/guardrails.py +0 -0
  27. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/hardware.py +0 -0
  28. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/health.py +0 -0
  29. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/identity.py +0 -0
  30. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/logger.py +0 -0
  31. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/metrics.py +0 -0
  32. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/pipeline.py +0 -0
  33. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/policy.py +0 -0
  34. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/ratelimit.py +0 -0
  35. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/schema.py +0 -0
  36. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/secret.py +0 -0
  37. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/signing.py +0 -0
  38. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh/snapshot.py +0 -0
  39. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh.egg-info/dependency_links.txt +0 -0
  40. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh.egg-info/entry_points.txt +0 -0
  41. {intermesh-0.3.0 → intermesh-0.4.1}/intermesh.egg-info/top_level.txt +0 -0
  42. {intermesh-0.3.0 → intermesh-0.4.1}/setup.cfg +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: intermesh
3
- Version: 0.3.0
3
+ Version: 0.4.1
4
4
  Summary: Universal coordination protocol for AI agents — E2E encrypted, cross-language, federated
5
5
  Author: InterMesh Protocol Authors
6
6
  License: Apache-2.0
@@ -26,6 +26,8 @@ Description-Content-Type: text/markdown
26
26
  Requires-Dist: websockets>=12.0
27
27
  Requires-Dist: PyJWT>=2.8.0
28
28
  Requires-Dist: cryptography>=42.0.0
29
+ Provides-Extra: postgres
30
+ Requires-Dist: psycopg[binary]>=3.1; extra == "postgres"
29
31
 
30
32
  # InterMesh — Python SDK
31
33
 
@@ -53,4 +53,4 @@ try:
53
53
  except ImportError:
54
54
  pass
55
55
 
56
- __version__ = "0.3.0"
56
+ __version__ = "0.4.1"
@@ -57,6 +57,8 @@ MUTATING = {
57
57
  "task.retry",
58
58
  "apikey.create",
59
59
  "apikey.revoke",
60
+ "approval.approve",
61
+ "approval.deny",
60
62
  "guardrails.set_policy",
61
63
  "escrow.grant",
62
64
  "escrow.release",
@@ -93,7 +95,14 @@ class AdminContext:
93
95
  asimov_engine: Any = None,
94
96
  escrow_manager: Any = None,
95
97
  snapshot_dir: Any = None,
98
+ pending_approvals: Any = None,
99
+ finalize_task: Callable | None = None,
96
100
  ):
101
+ # Tâches retenues en attente d'arbitrage humain, et le moyen de les
102
+ # relâcher. `finalize_task` est le même chemin que celui d'une
103
+ # soumission ordinaire : approuver ne doit pas livrer autrement.
104
+ self.pending_approvals = pending_approvals if pending_approvals is not None else {}
105
+ self.finalize_task = finalize_task
97
106
  self.snapshot_dir = snapshot_dir
98
107
  self.agents = agents
99
108
  self.identity_registry = identity_registry
@@ -274,6 +283,7 @@ def _tasks_list(ctx: AdminContext, params: dict) -> dict:
274
283
  {
275
284
  "task_id": t.task_id,
276
285
  "title": t.title,
286
+ "summary": t.summary,
277
287
  "orchestrator": t.orchestrator,
278
288
  "assignee": t.assignee,
279
289
  "status": t.status.value,
@@ -694,6 +704,91 @@ def _snapshot_restore(ctx: AdminContext, params: dict) -> dict:
694
704
  }
695
705
 
696
706
 
707
+ def _pending_in_scope(ctx: AdminContext, entry: dict) -> bool:
708
+ if not ctx.scoped:
709
+ return True
710
+ task = entry.get("task") or {}
711
+ return ctx.caller_org in (_org_of(entry.get("submitter")), _org_of(task.get("assignee")))
712
+
713
+
714
+ def _approvals_list(ctx: AdminContext, params: dict) -> dict:
715
+ """Tâches en attente d'un arbitrage humain.
716
+
717
+ La charge utile de la tâche n'est pas renvoyée : elle peut contenir les
718
+ données mêmes que l'organisation ne veut pas voir circuler. Ce qui est
719
+ montré est ce qu'il faut pour décider — qui demande, pour qui, et la
720
+ raison écrite dans la règle.
721
+ """
722
+ out = []
723
+ for task_id, entry in ctx.pending_approvals.items():
724
+ if not _pending_in_scope(ctx, entry):
725
+ continue
726
+ task = entry.get("task") or {}
727
+ out.append({
728
+ "task_id": task_id,
729
+ "title": task.get("title"),
730
+ "submitter": entry.get("submitter"),
731
+ "assignee": task.get("assignee"),
732
+ "rule": entry.get("rule"),
733
+ "reason": entry.get("reason"),
734
+ "estimated_cost": task.get("estimated_cost"),
735
+ "submitted_at": entry.get("submitted_at"),
736
+ })
737
+ out.sort(key=lambda e: e.get("submitted_at") or 0)
738
+ return {"pending": out, "count": len(out)}
739
+
740
+
741
+ def _pop_pending(ctx: AdminContext, params: dict) -> tuple[str, dict]:
742
+ task_id = params.get("task_id")
743
+ entry = ctx.pending_approvals.get(task_id)
744
+ if entry is None:
745
+ raise AdminError("Aucune tâche en attente d'approbation sous cet identifiant.")
746
+ if not _pending_in_scope(ctx, entry):
747
+ raise AdminError("Cette tâche est hors de votre organisation.")
748
+ return task_id, ctx.pending_approvals.pop(task_id)
749
+
750
+
751
+ async def _approval_approve(ctx: AdminContext, params: dict) -> dict:
752
+ """Relâche une tâche retenue, par le même chemin qu'une soumission normale.
753
+
754
+ Le séquestre est constitué maintenant, pas au moment de la soumission :
755
+ rien n'aura été immobilisé pendant l'attente. Le revers est qu'il peut
756
+ échouer ici, faute de fonds — l'erreur est alors renvoyée à
757
+ l'approbateur plutôt que masquée.
758
+ """
759
+ task_id, entry = _pop_pending(ctx, params)
760
+ approver = params.get("_approver") or "admin"
761
+
762
+ if ctx.finalize_task is None:
763
+ raise AdminError("Ce Hub ne sait pas relâcher une tâche approuvée.")
764
+
765
+ err = await ctx.finalize_task(entry["task"], entry["submitter"], entry.get("token"))
766
+ if err:
767
+ # La tâche ressort de la file : elle n'a pas été livrée, et la
768
+ # remettre en attente laisse une chance de réapprouver une fois le
769
+ # solde reconstitué, au lieu de la perdre.
770
+ ctx.pending_approvals[task_id] = entry
771
+ raise AdminError(f"Approuvée, mais non livrée : {err}")
772
+
773
+ ctx.audit_log.log("APPROVAL_GRANTED", approver, entry["task"].get("assignee"), {
774
+ "task_id": task_id, "rule": entry.get("rule"), "submitter": entry.get("submitter"),
775
+ })
776
+ return {"task_id": task_id, "status": "approved"}
777
+
778
+
779
+ def _approval_deny(ctx: AdminContext, params: dict) -> dict:
780
+ """Refuse une tâche retenue. Aucun séquestre n'existe, rien à défaire."""
781
+ task_id, entry = _pop_pending(ctx, params)
782
+ approver = params.get("_approver") or "admin"
783
+ note = params.get("note") or ""
784
+
785
+ ctx.audit_log.log("APPROVAL_DENIED", approver, entry["task"].get("assignee"), {
786
+ "task_id": task_id, "rule": entry.get("rule"),
787
+ "submitter": entry.get("submitter"), "note": note,
788
+ })
789
+ return {"task_id": task_id, "status": "denied", "note": note}
790
+
791
+
697
792
  HANDLERS: dict[str, Callable[..., Any]] = {
698
793
  "hub.info": _hub_info,
699
794
  "agents.list": _agents_list,
@@ -706,6 +801,9 @@ HANDLERS: dict[str, Callable[..., Any]] = {
706
801
  "apikeys.list": _apikeys_list,
707
802
  "apikey.create": _apikey_create,
708
803
  "apikey.revoke": _apikey_revoke,
804
+ "approvals.list": _approvals_list,
805
+ "approval.approve": _approval_approve,
806
+ "approval.deny": _approval_deny,
709
807
  "guardrails.policy": _guardrails_policy,
710
808
  "guardrails.set_policy": _guardrails_set_policy,
711
809
  "escrow.balance": _escrow_balance,
@@ -136,12 +136,34 @@ class InterMeshAgent:
136
136
  if "org_id" in res.content:
137
137
  self.identity.org_id = res.content["org_id"]
138
138
  self.org_id = res.content["org_id"]
139
+ self._learn_siblings(res.content.get("cluster_hubs"))
139
140
  print(f"✅ [{self.qualified_name}] Connecté sur {url} | E2E: {'🔒 ON' if self.encrypt else '🔓 OFF'}")
140
141
  elif res.type == MessageType.ERROR:
141
142
  print(f"❌ [{self.name}] Refusé : {res.content}")
142
143
  await ws.close()
143
144
  raise PermissionError(res.content)
144
145
 
146
+ def _learn_siblings(self, urls) -> None:
147
+ """Ajoute les Hubs frères annoncés par le Hub à la liste de repli.
148
+
149
+ Un agent ne connaît que l'adresse qu'on lui a donnée. Si ce Hub
150
+ meurt, la boucle de reconnexion rejoue indéfiniment la même adresse
151
+ morte, alors qu'un frère de la même grappe l'accepterait — c'est
152
+ mesurable, et c'était le comportement avant ceci.
153
+
154
+ Les énumérer à la main dans chaque agent serait possible, mais ne
155
+ survivrait pas à l'ajout d'un Hub : la liste vieillirait en silence,
156
+ ce qui revient à ne pas l'avoir. Le Hub, lui, sait qui est vivant.
157
+
158
+ Les adresses reçues sont *ajoutées*, jamais substituées : celle que
159
+ l'exploitant a écrite reste la première essayée.
160
+ """
161
+ if not isinstance(urls, list):
162
+ return
163
+ for candidate in urls:
164
+ if isinstance(candidate, str) and candidate and candidate not in self._hub_candidates:
165
+ self._hub_candidates.append(candidate)
166
+
145
167
  async def connect(self):
146
168
  """
147
169
  Connexion initiale. Avec plusieurs URLs de Hub, essaie chacune dans
@@ -311,6 +333,35 @@ class InterMeshAgent:
311
333
  try: return json.loads(encrypted_content)
312
334
  except json.JSONDecodeError: return encrypted_content
313
335
 
336
+ async def _respond_to_request(self, msg) -> None:
337
+ """Exécute le gestionnaire de requêtes et renvoie la réponse.
338
+
339
+ Séparée de la boucle d'écoute pour que celle-ci reste disponible :
340
+ le chiffrement de la réponse a besoin d'un aller-retour WHO_IS, que
341
+ la boucle doit pouvoir traiter pendant que cette tâche l'attend.
342
+ """
343
+ try:
344
+ handler = self._request_handler
345
+ reply = await handler(msg) if inspect.iscoroutinefunction(handler) else handler(msg)
346
+ reply = self._apply_egress(msg.sender, reply)
347
+ if self.encrypt:
348
+ pk = await self._fetch_public_key(msg.sender)
349
+ if pk:
350
+ reply = self._encrypt_content(pk, reply)
351
+ else:
352
+ # Sans clé, la réponse partirait en clair alors que
353
+ # l'appelant a demandé le chiffrement. Le dire est le
354
+ # minimum : une dégradation silencieuse vers le clair
355
+ # est pire qu'une erreur.
356
+ print(f"⚠️ [{self.qualified_name}] Clé publique de "
357
+ f"{msg.sender} introuvable : réponse NON chiffrée.")
358
+ await self.ws.send(InterMeshMessage(
359
+ type=MessageType.RESPONSE, sender=self.qualified_name, to=msg.sender,
360
+ reply_to=msg.id, content=reply, token=self.token,
361
+ ).to_json())
362
+ except Exception as exc:
363
+ print(f"⚠️ [{self.qualified_name}] Réponse impossible : {exc}")
364
+
314
365
  async def _listen_loop(self):
315
366
  try:
316
367
  async for raw in self.ws:
@@ -348,12 +399,15 @@ class InterMeshAgent:
348
399
  content = self._decrypt_content(content)
349
400
  msg.content = content
350
401
  if self._request_handler:
351
- reply = await self._request_handler(msg) if inspect.iscoroutinefunction(self._request_handler) else self._request_handler(msg)
352
- reply = self._apply_egress(msg.sender, reply)
353
- if self.encrypt:
354
- pk = await self._fetch_public_key(msg.sender)
355
- if pk: reply = self._encrypt_content(pk, reply)
356
- await self.ws.send(InterMeshMessage(type=MessageType.RESPONSE, sender=self.qualified_name, to=msg.sender, reply_to=msg.id, content=reply, token=self.token).to_json())
402
+ # Hors de la boucle, comme TASK_ASSIGN. Répondre ici
403
+ # même conduisait à un interblocage : chiffrer la
404
+ # réponse exige la clé publique du demandeur, obtenue
405
+ # par un WHO_IS dont la réponse ne peut être traitée
406
+ # que par cette boucle — alors bloquée à l'attendre.
407
+ # Le WHO_IS expirait donc systématiquement (3 s par
408
+ # requête) et, faute de clé, la réponse repartait en
409
+ # clair alors que le chiffrement était demandé.
410
+ asyncio.create_task(self._respond_to_request(msg))
357
411
 
358
412
  elif msg.type == MessageType.MESSAGE:
359
413
  content = msg.content
@@ -408,9 +462,13 @@ class InterMeshAgent:
408
462
  if self.encrypt:
409
463
  pk = await self._fetch_public_key(task.orchestrator)
410
464
  if pk: encrypted_output = self._encrypt_content(pk, encrypted_output)
411
- task.update_status(TaskStatus.COMPLETED, output_data=encrypted_output)
465
+ # task.summary peut avoir été renseigné par le handler (en clair,
466
+ # contrairement à output_data qui peut être chiffré) : sinon on
467
+ # retombe sur un résumé générique pour la page de résumés.
468
+ summary = task.summary or f"Tâche « {task.title} » terminée par {self.qualified_name}."
469
+ task.update_status(TaskStatus.COMPLETED, output_data=encrypted_output, summary=summary)
412
470
  except Exception as e:
413
- task.update_status(TaskStatus.FAILED, error_message=str(e))
471
+ task.update_status(TaskStatus.FAILED, error_message=str(e), summary=task.summary or f"Échec de « {task.title} » : {e}")
414
472
 
415
473
  await self.ws.send(InterMeshMessage(type=MessageType.TASK_UPDATE, sender=self.qualified_name, content=task.to_dict(), token=self.token).to_json())
416
474
  print(f"✅ [{self.qualified_name}] Tâche terminée: {task.status.value.upper()}")
@@ -0,0 +1,186 @@
1
+ """
2
+ Validation humaine : les tâches qu'un agent n'a pas le droit de lancer seul.
3
+
4
+ Les garde-fous Asimov répondent à « cette tâche est-elle légitime ? » et
5
+ refusent celles qui ne le sont pas. Ils ne répondent pas à « celle-ci est
6
+ légitime, mais quelqu'un doit-il la regarder avant ? ». Signer un contrat,
7
+ virer de l'argent, supprimer une base : ce sont des actions qu'un agent
8
+ peut vouloir légitimement, et qu'une organisation veut néanmoins tenir
9
+ sous décision humaine.
10
+
11
+ C'est la différence entre un refus et une suspension. Une règle qui
12
+ correspond ici ne rejette rien : elle met la tâche en attente jusqu'à ce
13
+ qu'une personne tranche.
14
+
15
+ Politique vide par défaut. Rien n'est suspendu tant que rien n'est
16
+ déclaré — un système qui met en attente ce qu'on ne lui a pas demandé de
17
+ retenir est un système qu'on désactive.
18
+
19
+ Portée : ce module décide *si* une tâche doit être approuvée. Il ne dit
20
+ rien de *qui* approuve — cette autorisation relève des rôles admin, au
21
+ même titre que les autres commandes d'administration.
22
+ """
23
+
24
+ from __future__ import annotations
25
+
26
+ import json
27
+ import re
28
+ from dataclasses import dataclass, field
29
+ from pathlib import Path
30
+ from typing import Any
31
+
32
+ ANY = "*"
33
+
34
+
35
+ @dataclass
36
+ class ApprovalRule:
37
+ """Une règle de mise en attente.
38
+
39
+ Tous les critères renseignés doivent correspondre — ils se cumulent au
40
+ lieu de s'additionner. Une règle sans aucun critère correspondrait à
41
+ toutes les tâches, ce que `__post_init__` refuse : le plus probable est
42
+ qu'on ait oublié de la remplir, pas qu'on veuille tout suspendre.
43
+
44
+ `reason` n'est pas décoratif : c'est le texte que lit la personne à qui
45
+ l'on demande de trancher. Sans lui, elle voit une tâche en attente sans
46
+ savoir ce qui l'y a mise.
47
+ """
48
+
49
+ name: str
50
+ reason: str
51
+ assignees: list[str] = field(default_factory=lambda: [ANY])
52
+ capabilities: list[str] = field(default_factory=lambda: [ANY])
53
+ pattern: str | None = None
54
+ min_cost: float | None = None
55
+ cross_org_only: bool = False
56
+
57
+ def __post_init__(self):
58
+ if not self.name:
59
+ raise ValueError("une règle d'approbation doit avoir un `name`")
60
+ if not self.reason:
61
+ raise ValueError(f"règle '{self.name}' : `reason` est obligatoire — "
62
+ "c'est ce que lit la personne qui approuve")
63
+ specific = (
64
+ self.assignees != [ANY]
65
+ or self.capabilities != [ANY]
66
+ or self.pattern is not None
67
+ or self.min_cost is not None
68
+ or self.cross_org_only
69
+ )
70
+ if not specific:
71
+ raise ValueError(
72
+ f"règle '{self.name}' : aucun critère, elle suspendrait toutes les "
73
+ "tâches. Précisez au moins assignees, capabilities, pattern, "
74
+ "min_cost ou cross_org_only."
75
+ )
76
+ self._compiled = re.compile(self.pattern, re.IGNORECASE) if self.pattern else None
77
+
78
+ def matches(
79
+ self,
80
+ assignee: str,
81
+ capabilities: list[str],
82
+ payload_text: str,
83
+ estimated_cost: float,
84
+ is_cross_org: bool,
85
+ ) -> bool:
86
+ if self.cross_org_only and not is_cross_org:
87
+ return False
88
+ if self.assignees != [ANY] and assignee not in self.assignees:
89
+ return False
90
+ if self.capabilities != [ANY] and not any(c in self.capabilities for c in capabilities):
91
+ return False
92
+ if self.min_cost is not None and estimated_cost < self.min_cost:
93
+ return False
94
+ if self._compiled is not None and not self._compiled.search(payload_text or ""):
95
+ return False
96
+ return True
97
+
98
+ @classmethod
99
+ def from_dict(cls, raw: dict) -> "ApprovalRule":
100
+ return cls(
101
+ name=raw["name"],
102
+ reason=raw["reason"],
103
+ assignees=raw.get("assignees", [ANY]),
104
+ capabilities=raw.get("capabilities", [ANY]),
105
+ pattern=raw.get("pattern"),
106
+ min_cost=raw.get("min_cost"),
107
+ cross_org_only=bool(raw.get("cross_org_only", False)),
108
+ )
109
+
110
+ def to_dict(self) -> dict:
111
+ return {
112
+ "name": self.name,
113
+ "reason": self.reason,
114
+ "assignees": self.assignees,
115
+ "capabilities": self.capabilities,
116
+ "pattern": self.pattern,
117
+ "min_cost": self.min_cost,
118
+ "cross_org_only": self.cross_org_only,
119
+ }
120
+
121
+
122
+ @dataclass
123
+ class ApprovalPolicy:
124
+ name: str = "empty"
125
+ rules: list[ApprovalRule] = field(default_factory=list)
126
+
127
+ @property
128
+ def is_empty(self) -> bool:
129
+ return not self.rules
130
+
131
+ @classmethod
132
+ def from_dict(cls, raw: dict) -> "ApprovalPolicy":
133
+ return cls(
134
+ name=raw.get("name", "unnamed"),
135
+ rules=[ApprovalRule.from_dict(r) for r in raw.get("rules", [])],
136
+ )
137
+
138
+ def to_dict(self) -> dict:
139
+ return {"name": self.name, "rules": [r.to_dict() for r in self.rules]}
140
+
141
+ @classmethod
142
+ def load(cls, path: str | Path) -> "ApprovalPolicy":
143
+ return cls.from_dict(json.loads(Path(path).read_text(encoding="utf-8")))
144
+
145
+
146
+ def _payload_text(task: dict) -> str:
147
+ """Texte sur lequel `pattern` est évalué.
148
+
149
+ Le titre et les données d'entrée sont concaténés : une règle qui
150
+ cherche « contrat » doit correspondre que le mot soit dans l'intitulé
151
+ ou dans la charge utile.
152
+ """
153
+ parts = [str(task.get("title") or "")]
154
+ data = task.get("input_data")
155
+ if data is not None:
156
+ parts.append(data if isinstance(data, str) else json.dumps(data, ensure_ascii=False))
157
+ return "\n".join(parts)
158
+
159
+
160
+ def requires_approval(
161
+ task: dict,
162
+ policy: ApprovalPolicy | None,
163
+ *,
164
+ capabilities: list[str] | None = None,
165
+ is_cross_org: bool = False,
166
+ ) -> ApprovalRule | None:
167
+ """Retourne la première règle qui met cette tâche en attente, sinon None.
168
+
169
+ La première suffit : une tâche est suspendue ou ne l'est pas, et c'est
170
+ le motif de cette règle qui sera montré à la personne qui tranche.
171
+ """
172
+ if policy is None or policy.is_empty:
173
+ return None
174
+
175
+ assignee = str(task.get("assignee") or "")
176
+ try:
177
+ cost = float(task.get("estimated_cost") or 0.0)
178
+ except (TypeError, ValueError):
179
+ cost = 0.0
180
+ text = _payload_text(task)
181
+ caps = capabilities or []
182
+
183
+ for rule in policy.rules:
184
+ if rule.matches(assignee, caps, text, cost, is_cross_org):
185
+ return rule
186
+ return None