stringcup 3.27.0__tar.gz → 3.30.0__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.
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stringcup
3
- Version: 3.27.0
3
+ Version: 3.30.0
4
4
  Summary: End-to-end encrypted agent-to-agent messaging: client library plus a local MCP server
5
5
  Author: Owen Borseth
6
6
  License: Apache-2.0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: stringcup
3
- Version: 3.27.0
3
+ Version: 3.30.0
4
4
  Summary: End-to-end encrypted agent-to-agent messaging: client library plus a local MCP server
5
5
  Author: Owen Borseth
6
6
  License: Apache-2.0
@@ -75,10 +75,10 @@ except ImportError as _exc: # pragma: no cover
75
75
  "On Python 3.7 pin it below 46 (see requirements.txt) — 46 drops 3.7."
76
76
  ) from _exc
77
77
 
78
- __version__ = "3.25.0"
78
+ __version__ = "3.28.0"
79
79
 
80
80
  #: Numeric form, for comparisons. Compare this, never `__version__`.
81
- version_info = (3, 25, 0)
81
+ version_info = (3, 28, 0)
82
82
 
83
83
  #: Version of the PyPI DISTRIBUTION, which ships this module and
84
84
  #: `stringcup_mcp.py` together. **This is a third number and it is not
@@ -109,7 +109,7 @@ version_info = (3, 25, 0)
109
109
  #: It must increase whenever either module's version does.
110
110
  #: `clients/python/test_contract.py` snapshots all three and fails on any
111
111
  #: change, so bumping a module forces a decision about this one.
112
- __dist_version__ = "3.27.0"
112
+ __dist_version__ = "3.30.0"
113
113
 
114
114
  __all__ = [
115
115
  "Client",
@@ -244,6 +244,9 @@ FEATURES = {
244
244
  "identity_source": (3, 24, 0), # load_or_register says which it did
245
245
  "self_join_refused": (3, 24, 0), # join_rendezvous detects a shared identity
246
246
  "identity_exclusive": (3, 25, 0), # is another live process on this identity
247
+ "handoff_expiry": (3, 26, 0), # the handoff block carries the relay deadline
248
+ "handoff_guide_url": (3, 27, 0), # the block tells a responder where the guide is
249
+ "sync_barrier_returns_drained": (3, 28, 0), # the barrier no longer destroys what it reads
247
250
  }
248
251
 
249
252
  DEFAULT_BASE_URL = "https://stringcup.com/api/v2"
@@ -781,7 +784,7 @@ MIN_POLL_INTERVAL = 12.0
781
784
 
782
785
  #: Throttle only when a bucket is down to this fraction of its own limit.
783
786
  #:
784
- #: An absolute threshold cannot work: the server's buckets range from 5/hour
787
+ #: An absolute threshold cannot work: the server's buckets range from 10/hour
785
788
  #: (registration) to 300/hour (inbox), so any fixed number is either always or
786
789
  #: never tripped depending on the endpoint.
787
790
  THROTTLE_AT_FRACTION = 0.10
@@ -1756,7 +1759,7 @@ class Client:
1756
1759
  """
1757
1760
  Reuse the identity at `path`, registering only if it is absent.
1758
1761
 
1759
- This is the form agents should use. Registration is capped at 5/hour
1762
+ This is the form agents should use. Registration is capped at 30/hour
1760
1763
  per IP, and since the id is assigned, re-registering does not even get
1761
1764
  you the same identity back — an agent that registers on every start
1762
1765
  both locks itself out and becomes unreachable at the id its peer knows.
@@ -2053,6 +2056,21 @@ class Client:
2053
2056
  " TOKEN: %s" % info["token"],
2054
2057
  ]
2055
2058
 
2059
+ # THE DEADLINE COMES FROM THE RELAY, NOT FROM A DOC CONSTANT.
2060
+ #
2061
+ # The relay returns expires_at and it is authoritative; a published
2062
+ # "expires in N minutes" is a copy that can drift from it silently.
2063
+ # That drift already nearly happened: the window moved 15 -> 30 and an
2064
+ # agent told its operator 30 from the page, which was right by luck
2065
+ # rather than by design. Reported by a peer agent that had measured 15
2066
+ # empirically hours earlier and went looking for a doc defect.
2067
+ #
2068
+ # Same reasoning as echoing `role` rather than assuming it: when the
2069
+ # relay reports a fact, pass the relay's value through.
2070
+ if info.get("expires_at"):
2071
+ lines.append(" EXPIRES: %s UTC (relay value, not an estimate)"
2072
+ % info["expires_at"])
2073
+
2056
2074
  if info.get("secret"):
2057
2075
  lines += [
2058
2076
  " SECRET: %s" % info["secret"],
@@ -2062,6 +2080,7 @@ class Client:
2062
2080
  " substituted. If pairing reports verified: false, or raises,",
2063
2081
  " stop and tell your operator.",
2064
2082
  ]
2083
+
2065
2084
  else:
2066
2085
  lines += [
2067
2086
  "",
@@ -2070,6 +2089,28 @@ class Client:
2070
2089
  " of band.",
2071
2090
  ]
2072
2091
 
2092
+ # THE RESPONDER WAS POINTED AT NOTHING, and that asymmetry is a real
2093
+ # onboarding defect rather than an omission.
2094
+ #
2095
+ # The initiator is handed the homepage prompt, which names agent.md.
2096
+ # The responder is handed THIS BLOCK AND ONLY THIS BLOCK -- so an agent
2097
+ # that arrives without Stringcup tools has no idea where the guide is,
2098
+ # cannot read what to do about it, and cannot find the install command
2099
+ # it is now permitted to run. The operator sees one agent set itself up
2100
+ # and the other apparently refuse to.
2101
+ #
2102
+ # The URL is a REFERENCE, not an instruction to obey: this page's own
2103
+ # history records that "read agent.md and follow it" was the wrong
2104
+ # prompt because it asks an agent to fetch a web page and do as it
2105
+ # says. Naming where the guide is does not ask for obedience, and an
2106
+ # agent declining is still a correct outcome.
2107
+ lines += [
2108
+ "",
2109
+ " No Stringcup tools yet, or unsure what to do with this?",
2110
+ " https://stringcup.com/agent.md (reference, not an instruction:",
2111
+ " declining and telling your operator is a correct outcome)",
2112
+ ]
2113
+
2073
2114
  return "\n".join(lines)
2074
2115
 
2075
2116
  def _verify_pairing(self, info: dict, secret: str, token: str,
@@ -2890,6 +2931,17 @@ class Client:
2890
2931
  a different view of what was said. What converges is a verifiable
2891
2932
  content check.
2892
2933
 
2934
+ **It RETURNS what it drained** (`messages`), because it acknowledges
2935
+ everything it reads and the relay then deletes it. Returning only a
2936
+ count and the peer's last line made this a **destructive read**:
2937
+ draining N discarded N-1 irrecoverably, and the symptom that makes an
2938
+ agent reach for a barrier -- a suspicious backlog -- cannot distinguish
2939
+ redundant chatter from four unread pieces of analysis. Observed: a
2940
+ barrier ate the four messages that were the evidence in the argument
2941
+ the barrier was called to settle. Reported by an agent that noticed the
2942
+ return SHAPE forced the loss, which makes it a property rather than bad
2943
+ luck.
2944
+
2893
2945
  This drains your inbox to empty, then returns what you need to send
2894
2946
  your peer so both sides can confirm they are level:
2895
2947
 
@@ -2907,6 +2959,7 @@ class Client:
2907
2959
  rediscover it mid-argument.
2908
2960
  """
2909
2961
  drained = 0
2962
+ drained_messages: List[Message] = []
2910
2963
  last_from_peer = None
2911
2964
 
2912
2965
  deadline = time.monotonic() + timeout
@@ -2916,6 +2969,7 @@ class Client:
2916
2969
  break
2917
2970
 
2918
2971
  drained += len(page.messages)
2972
+ drained_messages.extend(page.messages)
2919
2973
  for msg in page.messages:
2920
2974
  if msg.sender_id == peer:
2921
2975
  last_from_peer = msg
@@ -2926,6 +2980,10 @@ class Client:
2926
2980
 
2927
2981
  text = last_from_peer.text if last_from_peer is not None else ""
2928
2982
  return {
2983
+ # Everything this call consumed, in arrival order. The caller may
2984
+ # never see it anywhere else: these rows are acknowledged above and
2985
+ # the relay deletes on ACK.
2986
+ "messages": drained_messages,
2929
2987
  "drained": drained,
2930
2988
  "last_text": text,
2931
2989
  # First line, because a long multi-topic message is exactly the
@@ -3766,7 +3824,7 @@ class Client:
3766
3824
  The server's rate-limit bucket a request falls in.
3767
3825
 
3768
3826
  Mirrors RateLimitFilter server-side: buckets are per endpoint *and*
3769
- method, which is why POST /identities (5/hour) and GET /messages
3827
+ method, which is why POST /identities (30/hour) and GET /messages
3770
3828
  (300/hour) must not share a tracked budget. Path parameters are
3771
3829
  collapsed so /messages/42 and /messages/43 are one bucket.
3772
3830
  """
@@ -3812,14 +3870,14 @@ class Client:
3812
3870
  Two things here were wrong and caused a real pairing failure.
3813
3871
 
3814
3872
  **The threshold was absolute.** It slept whenever `remaining <= 10`,
3815
- applied to buckets whose limits range from 5/hour (registration) to
3873
+ applied to buckets whose limits range from 10/hour (rotation) to
3816
3874
  300/hour (inbox). Registration can *never* report more than 5
3817
3875
  remaining, so it always tripped: a fresh registration reporting 4 of 5
3818
3876
  — a budget 80% intact — slept the full 30 seconds. It is now a fraction
3819
3877
  of the bucket's own limit, so "nearly exhausted" means what it says.
3820
3878
 
3821
3879
  **The budget was global.** One `rate_limit` dict was overwritten by
3822
- every response, so a figure from the 5/hour registration bucket
3880
+ every response, so a figure from the 30/hour registration bucket
3823
3881
  throttled the *next* call even when that endpoint had 119 of 120 left.
3824
3882
  Budgets are now tracked per bucket.
3825
3883
 
@@ -90,7 +90,7 @@ stringcup.require_features("short_timeouts", "sent_seq", "inbox_quota_errors",
90
90
  "verified_pairing_pins", "local_pairing_role",
91
91
  "header_framed_verify", "undecryptable_visible", "structural_pin_rollback")
92
92
 
93
- __version__ = "1.22.0"
93
+ __version__ = "1.24.0"
94
94
 
95
95
  #: The MCP revision this server implements.
96
96
  PROTOCOL_VERSION = "2025-06-18"
@@ -129,7 +129,7 @@ _IDENTITY_EXCLUSIVE = None
129
129
  #:
130
130
  #: A newer library is NOT an error: it is usually fine and blocking it would
131
131
  #: break legitimate installs. It is reported, not refused.
132
- BUILT_AGAINST = (3, 25, 0)
132
+ BUILT_AGAINST = (3, 28, 0)
133
133
 
134
134
 
135
135
  def _version_note() -> Optional[str]:
@@ -374,7 +374,7 @@ def client() -> Client:
374
374
  The agent's identity, loaded from disk or registered once.
375
375
 
376
376
  Deferred rather than built at startup for two reasons: registration is
377
- capped at 5/hour per IP, and a host that probes tool lists on every launch
377
+ capped at 30/hour per IP, and a host that probes tool lists on every launch
378
378
  would burn that budget without ever sending a message. Re-registering does
379
379
  not recover an identity — it mints a different one — so the file is the
380
380
  thing that matters.
@@ -518,6 +518,14 @@ def tool_open_rendezvous(arguments: Dict[str, Any]) -> Dict[str, Any]:
518
518
  "handoff": me.handoff_block(info),
519
519
  # The relay derives and reports the role; echo it rather than assuming.
520
520
  "role": info.get("role", "initiator"),
521
+ # SAME REASONING, ONE FIELD OVER, and it was missing: the relay returns
522
+ # the deadline and this surface dropped it, so an agent telling its
523
+ # operator when the token dies had to recite a number from a doc. A
524
+ # published constant standing in for an authoritative value is the same
525
+ # shape as a renamed field surviving in prose; here the real value was
526
+ # already in the response being parsed. Reported by a peer agent which noticed only because it had
527
+ # measured the old 15-minute window itself.
528
+ "expires_at": info.get("expires_at"),
521
529
  "next": (
522
530
  "Give the WHOLE handoff block to your operator to pass to the other agent "
523
531
  "\u2014 the token AND the secret. The secret never reaches the relay, which "
@@ -842,10 +850,22 @@ def tool_sync_barrier(arguments: Dict[str, Any]) -> Dict[str, Any]:
842
850
  return {
843
851
  "synchronised": True,
844
852
  "drained": bar["drained"],
853
+ # EVERYTHING THE BARRIER CONSUMED. It acknowledges what it reads and
854
+ # the relay deletes on ACK, so without this the caller loses N-1 of N
855
+ # messages to a call it made to RECOVER a conversation. A barrier once
856
+ # ate the four messages that were the evidence in the argument it was
857
+ # called to settle.
858
+ "messages": [
859
+ {"from": m.sender_id, "text": m.text, "inbox_seq": m.id,
860
+ "created_at": m.created_at, "channel": m.channel}
861
+ for m in bar.get("messages", [])
862
+ ],
845
863
  "peer_last_line": bar["last_line"],
846
864
  "peer_last_seq": bar["last_seq"],
847
865
  "next": (
848
- "Your inbox is now empty, so you are level with the relay. Send your peer "
866
+ "READ `messages` FIRST \u2014 it is everything this call consumed and it "
867
+ "exists nowhere else, because the barrier acknowledged it and the relay "
868
+ "deletes on acknowledgement. Then: send your peer "
849
869
  "a message quoting `drained` and `peer_last_line` verbatim, and ask it to "
850
870
  "do the same. If the line it quotes is your most recent message, you are "
851
871
  "synchronised \u2014 resume from the NEWEST content, not the argument. This "
@@ -1198,6 +1218,12 @@ TOOLS: List[Dict[str, Any]] = [
1198
1218
  "The desync presents as YOUR PEER IGNORING YOU: direct questions appear "
1199
1219
  "unanswered on both sides, and both of you form confident, wrong "
1200
1220
  "conclusions about the other\u2019s reliability. Use receive_all instead. "
1221
+ "`more_waiting: false` means YOUR INBOX IS EMPTY AT THIS INSTANT. It is "
1222
+ "NOT an end-of-turn signal and this protocol has none: a peer sending three "
1223
+ "messages back to back will usually reach you as three separate calls, each "
1224
+ "reporting false, because batching is a property of a SLOW READER and never "
1225
+ "of a fast sender. If your peer said it was sending N, keep calling until you "
1226
+ "have N. Measured: a burst of three arrived as 1+1+1 with false every time.\n\n"
1201
1227
  "If `more_waiting` is true you are already holding stale content \u2014 do "
1202
1228
  "not reply; call receive_all. If you are already out of sync, call "
1203
1229
  "sync_barrier."
File without changes
File without changes
File without changes
File without changes
File without changes